diff --git a/.gitattributes b/.gitattributes index dfdb8b771..9ad1ee25e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,5 @@ *.sh text eol=lf +# Treat minified or packed JS/CSS files as binary, as they're not meant to be human-readable +*.min.* binary +*.map binary +*.pack.js binary diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 54dc5ca8c..5df769b94 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -7,11 +7,9 @@ about: Report a reproducible bug in the current release of NetBox ### Environment -* Python version: -* NetBox version: +* Python version: +* NetBox version: | +Each cable must have two endpoints defined. These endpoints are sometimes referenced as A and B for clarity, however cables are direction-agnostic and the order in which terminations are made has no meaning. Cables may be connected to the following objects: - Device A Patch Panel A Patch Panel B Device B -+-----------+ +-------------+ +-------------+ +-----------+ -| Interface | --- Cable --- | Front Port | | Front Port | --- Cable --- | Interface | -+-----------+ +-------------+ +-------------+ +-----------+ - +-------------+ +-------------+ - | Rear Port | --- Cable --- | Rear Port | - +-------------+ +-------------+ -``` +* Circuit terminations +* Console ports +* Console server ports +* Interfaces +* Pass-through ports (front and rear) +* Power feeds +* Power outlets +* Power ports -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. +Each cable may be assigned a type, label, length, and color. Each cable is also assigned one of three operational statuses: -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. +* Active (default) +* Planned +* Decommissioning + +## Tracing Cables + +A cable may be traced from either of its endpoints by clicking the "trace" button. (A REST API endpoint also provides this functionality.) NetBox will follow the path of connected cables from this termination across the directly connected cable to the far-end termination. If the cable connects to a pass-through port, and the peer port has another cable connected, NetBox will continue following the cable path until it encounters a non-pass-through or unconnected termination point. The entire path will be displayed to the user. + +In the example below, three individual cables comprise a path between devices A and D: + +![Cable path](../../media/models/dcim_cable_trace.png) + +Traced from Interface 1 on Device A, NetBox will show the following path: + +* Cable 1: Interface 1 to Front Port 1 +* Cable 2: Rear Port 1 to Rear Port 2 +* Cable 3: Front Port 2 to Interface 2 diff --git a/docs/models/dcim/consoleport.md b/docs/models/dcim/consoleport.md index 4d3a089c5..1a0782f25 100644 --- a/docs/models/dcim/consoleport.md +++ b/docs/models/dcim/consoleport.md @@ -1,5 +1,5 @@ ## Console Ports -A console port provides connectivity to the physical console of a device. Console ports are typically used for temporary access by someone who is physically near the device, or for remote out-of-band access via a console server. +A console port provides connectivity to the physical console of a device. These are typically used for temporary access by someone who is physically near the device, or for remote out-of-band access provided via a networked console server. Each console port may be assigned a physical type. -Console ports can be connected to console server ports. +Cables can connect console ports to console server ports or pass-through ports. diff --git a/docs/models/dcim/consoleporttemplate.md b/docs/models/dcim/consoleporttemplate.md index 86281cb92..3462ff253 100644 --- a/docs/models/dcim/consoleporttemplate.md +++ b/docs/models/dcim/consoleporttemplate.md @@ -1,3 +1,3 @@ ## Console Port Templates -A template for a console port that will be created on all instantiations of the parent device type. +A template for a console port that will be created on all instantiations of the parent device type. Each console port can be assigned a physical type. diff --git a/docs/models/dcim/consoleserverport.md b/docs/models/dcim/consoleserverport.md index 55aefd733..da1ee8986 100644 --- a/docs/models/dcim/consoleserverport.md +++ b/docs/models/dcim/consoleserverport.md @@ -1,5 +1,5 @@ ## Console Server Ports -A console server is a device which provides remote access to the local consoles of connected devices. This is typically done to provide remote out-of-band access to network devices. +A console server is a device which provides remote access to the local consoles of connected devices. They are typically used to provide remote out-of-band access to network devices. Each console server port may be assigned a physical type. -Console server ports can be connected to console ports. +Cables can connect console server ports to console ports or pass-through ports. diff --git a/docs/models/dcim/consoleserverporttemplate.md b/docs/models/dcim/consoleserverporttemplate.md index ed99adb11..cc4e8bcd3 100644 --- a/docs/models/dcim/consoleserverporttemplate.md +++ b/docs/models/dcim/consoleserverporttemplate.md @@ -1,3 +1,3 @@ ## Console Server Port Templates -A template for a console server port that will be created on all instantiations of the parent device type. +A template for a console server port that will be created on all instantiations of the parent device type. Each console server port can be assigned a physical type. diff --git a/docs/models/dcim/device.md b/docs/models/dcim/device.md index 9ec2875da..df14c0e07 100644 --- a/docs/models/dcim/device.md +++ b/docs/models/dcim/device.md @@ -1,7 +1,15 @@ # Devices -Every piece of hardware which is installed within a rack exists in NetBox as a device. Devices are measured in rack units (U) and can be half depth or full depth. A device may have a height of 0U: These devices do not consume vertical rack space and cannot be assigned to a particular rack unit. A common example of a 0U device is a vertically-mounted PDU. +Every piece of hardware which is installed within a site or rack exists in NetBox as a device. Devices are measured in rack units (U) and can be half depth or full depth. A device may have a height of 0U: These devices do not consume vertical rack space and cannot be assigned to a particular rack unit. A common example of a 0U device is a vertically-mounted PDU. When assigning a multi-U device to a rack, it is considered to be mounted in the lowest-numbered rack unit which it occupies. For example, a 3U device which occupies U8 through U10 is said to be mounted in U8. This logic applies to racks with both ascending and descending unit numbering. -A device is said to be full depth if its installation on one rack face prevents the installation of any other device on the opposite face within the same rack unit(s). This could be either because the device is physically too deep to allow a device behind it, or because the installation of an opposing device would impede airflow. +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. + +Each device must be instantiated from a pre-created device type, and its default components (console ports, power ports, interfaces, etc.) will be created automatically. (The device type associated with a device may be changed after its creation, however its components will not be updated retroactively.) + +Each device must be assigned a site, device role, and operational status, and may optionally be assigned to a specific rack within a site. A platform, serial number, and asset tag may optionally be assigned to each device. + +Device names must be unique within a site, unless the device has been assigned to a tenant. Devices may also be unnamed. + +When a device has one or more interfaces with IP addresses assigned, a primary IP for the device can be designated, for both IPv4 and IPv6. diff --git a/docs/models/dcim/devicebay.md b/docs/models/dcim/devicebay.md index cdcd5657d..2aea14a7a 100644 --- a/docs/models/dcim/devicebay.md +++ b/docs/models/dcim/devicebay.md @@ -1,7 +1,8 @@ ## 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 or the "Non-Racked Devices" list within the rack view. +Device bays represent a space or slot within a parent device in which a child device may be installed. For example, a 2U parent chassis might house four individual blade servers. The chassis would appear in the rack elevation as a 2U device with four device bays, and 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 or count as consuming rack units. -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. +Child devices are first-class Devices in their own right: That is, they are 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 components. LAG interfaces may not group interfaces belonging to 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. +!!! note + Device bays are **not** suitable for modeling line cards (such as those commonly found in chassis-based routers and switches), as these components depend on the control plane of the parent device to operate. Instead, line cards and similarly non-autonomous hardware should be modeled as inventory items within a device, with any associated interfaces or other components assigned directly to the device. diff --git a/docs/models/dcim/devicerole.md b/docs/models/dcim/devicerole.md index 315f81356..13b8f021e 100644 --- a/docs/models/dcim/devicerole.md +++ b/docs/models/dcim/devicerole.md @@ -1,3 +1,3 @@ # 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. +Devices can be organized by functional roles, which are fully customizable by the user. For example, you might create roles for core switches, distribution switches, and access switches within your network. diff --git a/docs/models/dcim/devicetype.md b/docs/models/dcim/devicetype.md index 1a10cee41..a7e00dbc6 100644 --- a/docs/models/dcim/devicetype.md +++ b/docs/models/dcim/devicetype.md @@ -1,18 +1,14 @@ # Device Types -A device type represents a particular make and model of hardware that exists in the real world. Device types define the physical attributes of a device (rack height and depth) and its individual components (console, power, and network interfaces). +A device type represents a particular make and model of hardware that exists in the real world. Device types define the physical attributes of a device (rack height and depth) and its individual components (console, power, network interfaces, and so on). -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.) +Device types are instantiated as devices installed within sites and/or equipment 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 _instances_ of this type named "switch1," "switch2," and so on. Each device will automatically 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. Some devices house child devices which share physical resources, like space and power, but which functional independently from one another. A common example of this is blade server chassis. Each device type is designated as one of the following: * A parent device (which has device bays) -* A child device (which must be installed in a device bay) +* A child device (which must be installed within a device bay) * Neither !!! note - This parent/child relationship is **not** suitable for modeling chassis-based devices, wherein child members share a common control plane. - - 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) + This parent/child relationship is **not** suitable for modeling chassis-based devices, wherein child members share a common control plane. Instead, line cards and similarly non-autonomous hardware should be modeled as inventory items within a device, with any associated interfaces or other components assigned directly to the device. diff --git a/docs/models/dcim/frontport.md b/docs/models/dcim/frontport.md index 12b9cfc16..0b753c012 100644 --- a/docs/models/dcim/frontport.md +++ b/docs/models/dcim/frontport.md @@ -1,5 +1,3 @@ ## Front Ports -Front ports are pass-through ports used to represent physical cable connections that comprise part of a longer path. For example, the ports on the front face of a UTP patch panel would be modeled in NetBox as front ports. - -Each front port is mapped to a specific rear port on the same device. A single rear port may be mapped to multiple rear ports. \ No newline at end of file +Front ports are pass-through ports used to represent physical cable connections that comprise part of a longer path. For example, the ports on the front face of a UTP patch panel would be modeled in NetBox as front ports. Each port is assigned a physical type, and must be mapped to a specific rear port on the same device. A single rear port may be mapped to multiple rear ports, using numeric positions to annotate the specific alignment of each. diff --git a/docs/models/dcim/frontporttemplate.md b/docs/models/dcim/frontporttemplate.md index b32349519..03de0eae4 100644 --- a/docs/models/dcim/frontporttemplate.md +++ b/docs/models/dcim/frontporttemplate.md @@ -1,3 +1,3 @@ ## Front Port Templates -A template for a front-facing pass-through port that will be created on all instantiations of the parent device type. +A template for a front-facing pass-through port that will be created on all instantiations of the parent device type. Front ports may have a physical type assigned, and must be associated with a corresponding rear port and position. This association will be automatically replicated when the device type is instantiated. diff --git a/docs/models/dcim/interface.md b/docs/models/dcim/interface.md index cbccbec8d..756e320af 100644 --- a/docs/models/dcim/interface.md +++ b/docs/models/dcim/interface.md @@ -1,9 +1,12 @@ ## 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*. +Interfaces in NetBox represent network interfaces used to exchange data with connected devices. On modern networks, these are most commonly Ethernet, but other types are supported as well. Each interface must be assigned a type, and may optionally be assigned a MAC address, MTU, and IEEE 802.1Q mode (tagged or access). Each interface can also be enabled or disabled, and optionally designated as management-only (for out-of-band management). -Each interface is a assigned a type denoting its physical properties. Two special types exist: the "virtual" type can be used to designate logical interfaces (such as SVIs), and the "LAG" type can be used to desinate link aggregation groups to which physical interfaces can be assigned. +Interfaces may be physical or virtual in nature, but only physical interfaces may be connected via cables. Cables can connect interfaces to pass-through ports, circuit terminations, or other interfaces. -Each interface can also be enabled or disabled, and optionally designated as management-only (for out-of-band management). Fields are also provided to store an interface's MTU and MAC address. +Physical interfaces may be arranged into a link aggregation group (LAG) and associated with a parent LAG (virtual) interface. LAG interfaces can be recursively nested to model bonding of trunk groups. Like all virtual interfaces, LAG interfaces cannot be connected physically. -VLANs can be assigned to each interface as either tagged or untagged. (An interface may have only one untagged VLAN.) +IP addresses can be assigned to interfaces. VLANs can also be assigned to each interface as either tagged or untagged. (An interface may have only one untagged VLAN.) + +!!! note + Although devices and virtual machines both can have interfaces, a separate model is used for each. Thus, device interfaces have some properties that are not present on virtual machine interfaces and vice versa. diff --git a/docs/models/dcim/interfacetemplate.md b/docs/models/dcim/interfacetemplate.md index 07fc3a65b..d9b30dd87 100644 --- a/docs/models/dcim/interfacetemplate.md +++ b/docs/models/dcim/interfacetemplate.md @@ -1,3 +1,3 @@ ## Interface Templates -A template for an interface that will be created on all instantiations of the parent device type. +A template for a network interface that will be created on all instantiations of the parent device type. Each interface may be assigned a physical or virtual type, and may be designated as "management-only." diff --git a/docs/models/dcim/inventoryitem.md b/docs/models/dcim/inventoryitem.md index b113dce1e..237bad92c 100644 --- a/docs/models/dcim/inventoryitem.md +++ b/docs/models/dcim/inventoryitem.md @@ -1,3 +1,7 @@ # Inventory Items -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. +Inventory items represent hardware components installed within a device, such as a power supply or CPU or line card. Inventory items are distinct from other device components in that they cannot be templatized on a device type, and cannot be connected by cables. They are intended to be used primarily for inventory purposes. + +Each inventory item can be assigned a manufacturer, part ID, serial number, and asset tag (all optional). A boolean toggle is also provided to indicate whether each item was entered manually or discovered automatically (by some process outside of NetBox). + +Inventory items are hierarchical in nature, such that any individual item may be designated as the parent for other items. For example, an inventory item might be created to represent a line card which houses several SFP optics, each of which exists as a child item within the device. diff --git a/docs/models/dcim/manufacturer.md b/docs/models/dcim/manufacturer.md index cee89291d..df227ee17 100644 --- a/docs/models/dcim/manufacturer.md +++ b/docs/models/dcim/manufacturer.md @@ -1,3 +1,3 @@ # Manufacturers -Each device type must be assigned to a manufacturer. The model number of a device type must be unique to its manufacturer. +A manufacturer represents the "make" of a device; e.g. Cisco or Dell. Each device type must be assigned to a manufacturer. (Inventory items and platforms may also be associated with manufacturers.) Each manufacturer must have a unique name and may have a description assigned to it. diff --git a/docs/models/dcim/platform.md b/docs/models/dcim/platform.md index 19528da13..a860904b5 100644 --- a/docs/models/dcim/platform.md +++ b/docs/models/dcim/platform.md @@ -1,7 +1,9 @@ # Platforms -A platform defines the type of software running on a device or virtual machine. This can be helpful when it is necessary to distinguish between, for instance, different feature sets. Note that two devices of the same type may be assigned different platforms: for example, one Juniper MX240 running Junos 14 and another running Junos 15. +A platform defines the type of software running on a device or virtual machine. This can be helpful to model when it is necessary to distinguish between different versions or feature sets. Note that two devices of the same type may be assigned different platforms: For example, one Juniper MX240 might run Junos 14 while another runs Junos 15. -The platform model is also used to indicate which [NAPALM](https://napalm-automation.net/) driver NetBox should use when connecting to a remote device. The name of the driver along with optional parameters are stored with the platform. +Platforms may optionally be limited by manufacturer: If a platform is assigned to a particular manufacturer, it can only be assigned to devices with a type belonging to that manufacturer. + +The platform model is also used to indicate which [NAPALM](https://napalm-automation.net/) driver and any associated arguments NetBox should use when connecting to a remote device. The name of the driver along with optional parameters are stored with the platform. The assignment of platforms to devices is an optional feature, and may be disregarded if not desired. diff --git a/docs/models/dcim/powerfeed.md b/docs/models/dcim/powerfeed.md index ab8621e14..48ad2a5dc 100644 --- a/docs/models/dcim/powerfeed.md +++ b/docs/models/dcim/powerfeed.md @@ -1,8 +1,21 @@ # Power Feed -A power feed identifies the power outlet/drop that goes to a rack and is terminated to a power panel. Power feeds have a supply type (AC/DC), voltage, amperage, and phase type (single/three). +A power feed represents the distribution of power from a power panel to a particular device, typically a power distribution unit (PDU). The power pot (inlet) on a device can be connected via a cable to a power feed. A power feed may optionally be assigned to a rack to allow more easily tracking the distribution of power among racks. -Power feeds are optionally assigned to a rack. In addition, a power port – and only one – can connect to a power feed; in the context of a PDU, the power feed is analogous to the power outlet that a PDU's power port/inlet connects to. +Each power feed is assigned an operational type (primary or redundant) and one of the following statuses: + +* Offline +* Active +* Planned +* Failed + +Each power feed also defines the electrical characteristics of the circuit which it represents. These include the following: + +* Supply type (AC or DC) +* Phase (single or three-phase) +* Voltage +* Amperage +* Maximum utilization (percentage) !!! info - The power usage of a rack is calculated when a power feed (or multiple) is assigned to that rack and connected to a power port. + The power utilization of a rack is calculated when one or more power feeds are assigned to the rack and connected to devices that draw power. diff --git a/docs/models/dcim/poweroutlet.md b/docs/models/dcim/poweroutlet.md index 0ec93856e..e9ef307bd 100644 --- a/docs/models/dcim/poweroutlet.md +++ b/docs/models/dcim/poweroutlet.md @@ -1,3 +1,7 @@ ## Power Outlets -Power outlets represent the ports on a PDU that supply power to other devices. Power outlets are downstream-facing towards power ports. A power outlet can be associated with a power port on the same device and a feed leg (i.e. in a case of a three-phase supply). This indicates which power port supplies power to a power outlet. +Power outlets represent the outlets on a power distribution unit (PDU) or other device that supply power to dependent devices. Each power port may be assigned a physical type, and may be associated with a specific feed leg (where three-phase power is used) and/or a specific upstream power port. This association can be used to model the distribution of power within a device. + +For example, imagine a PDU with one power port which draws from a three-phase feed and 48 power outlets arranged into three banks of 16 outlets each. Outlets 1-16 would be associated with leg A on the port, and outlets 17-32 and 33-48 would be associated with legs B and C, respectively. + +Cables can connect power outlets only to downstream power ports. (Pass-through ports cannot be used to model power distribution.) diff --git a/docs/models/dcim/poweroutlettemplate.md b/docs/models/dcim/poweroutlettemplate.md index e5b54af23..6f81891f1 100644 --- a/docs/models/dcim/poweroutlettemplate.md +++ b/docs/models/dcim/poweroutlettemplate.md @@ -1,3 +1,3 @@ ## Power Outlet Templates -A template for a power outlet that will be created on all instantiations of the parent device type. +A template for a power outlet that will be created on all instantiations of the parent device type. Each power outlet can be assigned a physical type, and its power source may be mapped to a specific feed leg and power port template. This association will be automatically replicated when the device type is instantiated. diff --git a/docs/models/dcim/powerpanel.md b/docs/models/dcim/powerpanel.md index 3b05f8fad..3daecbacf 100644 --- a/docs/models/dcim/powerpanel.md +++ b/docs/models/dcim/powerpanel.md @@ -1,3 +1,8 @@ # Power Panel -A power panel represents the distribution board where power circuits – and their circuit breakers – terminate on. If you have multiple power panels in your data center, you should model them as such in NetBox to assist you in determining the redundancy of your power allocation. +A power panel represents the origin point in NetBox for electrical power being disseminated by one or more power feeds. In a data center environment, one power panel often serves a group of racks, with an individual power feed extending to each rack, though this is not always the case. It is common to have two sets of panels and feeds arranged in parallel to provide redundant power to each rack. + +Each power panel must be assigned to a site, and may optionally be assigned to a particular rack group. + +!!! note + NetBox does not model the mechanism by which power is delivered to a power panel. Power panels define the root level of the power distribution hierarchy in NetBox. diff --git a/docs/models/dcim/powerport.md b/docs/models/dcim/powerport.md index 6027fa98b..1948920d0 100644 --- a/docs/models/dcim/powerport.md +++ b/docs/models/dcim/powerport.md @@ -1,6 +1,8 @@ ## Power Ports -A power port is the inlet of a device where it draws its power. Power ports are upstream-facing towards power outlets. Alternatively, a power port can connect to a power feed – as mentioned in the power feed section – to indicate the power source of a PDU's inlet. +A power port represents the inlet of a device where it draws its power, i.e. the connection port(s) on a device's power supply. Each power port may be assigned a physical type, as well as allocated and maximum draw values (in watts). These values can be used to calculate the overall utilization of an upstream power feed. !!! info - If the draw of a power port is left empty, it will be dynamically calculated based on the power outlets associated with that power port. This is usually the case on the power ports of devices that supply power, like a PDU. + When creating a power port on a device which supplies power to downstream devices, the allocated and maximum draw numbers should be left blank. Utilization will be calculated by taking the sum of all power ports of devices connected downstream. + +Cables can connect power ports only to power outlets or power feeds. (Pass-through ports cannot be used to model power distribution.) diff --git a/docs/models/dcim/powerporttemplate.md b/docs/models/dcim/powerporttemplate.md index b6e64be01..947f146ae 100644 --- a/docs/models/dcim/powerporttemplate.md +++ b/docs/models/dcim/powerporttemplate.md @@ -1,3 +1,3 @@ ## Power Port Templates -A template for a power port that will be created on all instantiations of the parent device type. +A template for a power port that will be created on all instantiations of the parent device type. Each power port can be assigned a physical type, as well as a maximum and allocated draw in watts. diff --git a/docs/models/dcim/rack.md b/docs/models/dcim/rack.md index 39858b823..e5e52cc07 100644 --- a/docs/models/dcim/rack.md +++ b/docs/models/dcim/rack.md @@ -1,8 +1,10 @@ # Racks -The rack model represents a physical two- or four-post equipment rack in which equipment is mounted. Each rack must be assigned to a site. Rack height is measured in *rack units* (U); racks are commonly between 42U and 48U tall, but NetBox allows you to define racks of arbitrary height. A toggle is provided to indicate whether rack units are in ascending or descending order. +The rack model represents a physical two- or four-post equipment rack in which devices can be installed. Each rack must be assigned to a site, and may optionally be assigned to a rack group and/or tenant. Racks can also be organized by user-defined functional roles. -Each rack is assigned a name and (optionally) a separate facility ID. This is helpful when leasing space in a data center your organization does not own: The facility will often assign a seemingly arbitrary ID to a rack (for example, "M204.313") whereas internally you refer to is simply as "R113." A unique serial number may also be associated with each rack. +Rack height is measured in *rack units* (U); racks are commonly between 42U and 48U tall, but NetBox allows you to define racks of arbitrary height. A toggle is provided to indicate whether rack units are in ascending (from the ground up) or descending order. + +Each rack is assigned a name and (optionally) a separate facility ID. This is helpful when leasing space in a data center your organization does not own: The facility will often assign a seemingly arbitrary ID to a rack (for example, "M204.313") whereas internally you refer to is simply as "R113." A unique serial number and asset tag may also be associated with each rack. A rack must be designated as one of the following types: @@ -12,4 +14,12 @@ A rack must be designated as one of the following types: * Wall-mounted frame * Wall-mounted cabinet -Each rack has two faces (front and rear) on which devices can be mounted. Rail-to-rail width may be 19 or 23 inches. +Similarly, each rack must be assigned an operational status, which is one of the following: + +* Reserved +* Available +* Planned +* Active +* Deprecated + +Each rack has two faces (front and rear) on which devices can be mounted. Rail-to-rail width may be 10, 19, 21, or 23 inches. The outer width and depth of a rack or cabinet can also be annotated in millimeters or inches. diff --git a/docs/models/dcim/rackgroup.md b/docs/models/dcim/rackgroup.md index f5b2428e6..974285f71 100644 --- a/docs/models/dcim/rackgroup.md +++ b/docs/models/dcim/rackgroup.md @@ -1,7 +1,7 @@ # Rack Groups -Racks can be arranged into groups. As with sites, how you choose to designate rack groups will depend on the nature of your organization. For example, if each site represents a campus, each group might represent a building within a campus. If each site represents a building, each rack group might equate to a floor or room. +Racks can be organized into groups, which can be nested into themselves similar to regions. As with sites, how you choose to designate rack groups will depend on the nature of your organization. For example, if each site represents a campus, each group might represent a building within a campus. If each site represents a building, each rack group might equate to a floor or room. -Each rack group must be assigned to a parent site, and rack groups may optionally be nested to achieve a multi-level hierarchy. +Each rack group must be assigned to a parent site, and rack groups may optionally be nested within a site to model a multi-level hierarchy. For example, you might have a tier of rooms beneath a tier of floors, all belonging to the same parent building (site). The name and facility ID of each rack within a group must be unique. (Racks not assigned to the same rack group may have identical names and/or facility IDs.) diff --git a/docs/models/dcim/rackreservation.md b/docs/models/dcim/rackreservation.md index 09de55553..0ed9651a0 100644 --- a/docs/models/dcim/rackreservation.md +++ b/docs/models/dcim/rackreservation.md @@ -1,3 +1,3 @@ # Rack Reservations -Users can reserve units within a rack for future use. Multiple non-contiguous rack units can be associated with a single reservation (but reservations cannot span multiple racks). A rack reservation may optionally designate a specific tenant. +Users can reserve specific units within a rack for future use. An arbitrary set of units within a rack can be associated with a single reservation, but reservations cannot span multiple racks. A description is required for each reservation, reservations may optionally be associated with a specific tenant. diff --git a/docs/models/dcim/rackrole.md b/docs/models/dcim/rackrole.md index 63e9c1469..1375ce692 100644 --- a/docs/models/dcim/rackrole.md +++ b/docs/models/dcim/rackrole.md @@ -1,3 +1,3 @@ # Rack Roles -Each rack can optionally be assigned a functional role. For example, you might designate a rack for compute or storage resources, or to house colocated customer devices. Rack roles are fully customizable. +Each rack can optionally be assigned a user-defined functional role. For example, you might designate a rack for compute or storage resources, or to house colocated customer devices. Rack roles are fully customizable and may be color-coded. diff --git a/docs/models/dcim/rearport.md b/docs/models/dcim/rearport.md index 8c8136338..41c5b3037 100644 --- a/docs/models/dcim/rearport.md +++ b/docs/models/dcim/rearport.md @@ -1,5 +1,6 @@ ## Rear Ports -Like front ports, rear ports are pass-through ports which represent the end of a particular cable segment in a path. Each rear port is defined with a number of positions: rear ports with more than one position can be mapped to multiple front ports. 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). +Like front ports, rear ports are pass-through ports which represent the continuation of a path from one cable to the next. Each rear port is defined with its physical type and a number of positions: Rear ports with more than one position can be mapped to multiple front ports. This can be useful for modeling instances where multiple paths share a common cable (for example, six discrete two-strand fiber connections sharing a 12-strand MPO cable). -Note that front and rear ports need not necessarily reside on the actual front or rear device face. This terminology is used primarily to distinguish between the two components in a pass-through port pairing. +!!! note + Front and rear ports need not necessarily reside on the actual front or rear device face. This terminology is used primarily to distinguish between the two components in a pass-through port pairing. diff --git a/docs/models/dcim/rearporttemplate.md b/docs/models/dcim/rearporttemplate.md index 448c0befd..01ba02ac0 100644 --- a/docs/models/dcim/rearporttemplate.md +++ b/docs/models/dcim/rearporttemplate.md @@ -1,3 +1,3 @@ ## Rear Port Templates -A template for a rear-facing pass-through port that will be created on all instantiations of the parent device type. +A template for a rear-facing pass-through port that will be created on all instantiations of the parent device type. Each rear port may have a physical type and one or more front port templates assigned to it. The number of positions associated with a rear port determines how many front ports can be assigned to it (the maximum is 1024). diff --git a/docs/models/dcim/site.md b/docs/models/dcim/site.md index b13056a99..6617b950c 100644 --- a/docs/models/dcim/site.md +++ b/docs/models/dcim/site.md @@ -1,13 +1,15 @@ # Sites -How you choose to use sites will depend on the nature of your organization, but typically a site will equate to a building or campus. For example, a chain of banks might create a site to represent each of its branches, a site for its corporate headquarters, and two additional sites for its presence in two colocation facilities. +How you choose to employ sites when modeling your network may vary depending on the nature of your organization, but generally a site will equate to a building or campus. For example, a chain of banks might create a site to represent each of its branches, a site for its corporate headquarters, and two additional sites for its presence in two colocation facilities. -Each site must be assigned one of the following operational statuses: +Each site must be assigned a unique name and may optionally be assigned to a region and/or tenant. The following operational statuses are available: -* Active * Planned +* Staging +* Active +* Decommissioning * Retired -The site model provides a facility ID field which can be used to annotate a facility ID (such as a datacenter name) associated with the site. Each site may also have an autonomous system (AS) number and time zone associated with it. (Time zones are provided by the [pytz](https://pypi.org/project/pytz/) package.) +The site model also provides a facility ID field which can be used to annotate a facility ID (such as a datacenter name) associated with the site. Each site may also have an autonomous system (AS) number and time zone associated with it. (Time zones are provided by the [pytz](https://pypi.org/project/pytz/) package.) -The site model also includes several fields for storing contact and address information. +The site model also includes several fields for storing contact and address information as well as geolocation data (GPS coordinates). diff --git a/docs/models/dcim/virtualchassis.md b/docs/models/dcim/virtualchassis.md index e1707918b..b2a7d3bc9 100644 --- a/docs/models/dcim/virtualchassis.md +++ b/docs/models/dcim/virtualchassis.md @@ -1,5 +1,8 @@ # Virtual Chassis -A virtual chassis represents a set of devices which share a single control plane: a stack of switches which are managed as a single device, for example. Each device in the virtual chassis is assigned a position and (optionally) a priority. Exactly one device is designated the virtual chassis master: This device will typically be assigned a name, secrets, services, and other attributes related to its management. +A virtual chassis represents a set of devices which share a common control plane. A common example of this is a stack of switches which are connected and configured to operate as a single device. A virtual chassis must be assigned a name and may be assigned a domain. -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. +Each device in the virtual chassis is referred to as a VC member, and assigned a position and (optionally) a priority. VC member devices commonly reside within the same rack, though this is not a requirement. One of the devices may be designated as the VC master: This device will typically be assigned a name, secrets, services, and other attributes related to managing the VC. + +!!! note + It's important to recognize the distinction between a virtual chassis and a chassis-based device. A virtual chassis is **not** suitable for modeling a chassis-based switch with removable line cards (such as the Juniper EX9208), as its line cards are _not_ physically autonomous devices. diff --git a/docs/models/extras/configcontext.md b/docs/models/extras/configcontext.md index 380e631d8..af81cfbf9 100644 --- a/docs/models/extras/configcontext.md +++ b/docs/models/extras/configcontext.md @@ -1,5 +1,65 @@ # Configuration Contexts -Sometimes it is desirable to associate arbitrary data with a group of devices to aid in their configuration. For example, you might want to associate a set of syslog servers for all devices at a particular site. Context data enables the association of arbitrary data to devices and virtual machines grouped by region, site, role, platform, and/or tenant. Context data is arranged hierarchically, so that data with a higher weight can be entered to override more general lower-weight data. Multiple instances of data are automatically merged by NetBox to present a single dictionary for each object. +Sometimes it is desirable to associate additional data with a group of devices or virtual machines to aid in automated configuration. For example, you might want to associate a set of syslog servers for all devices within a particular region. Context data enables the association of extra user-defined data with devices and virtual machines grouped by one or more of the following assignments: -Devices and Virtual Machines may also have a local config context defined. This local context will always overwrite the rendered config context objects for the Device/VM. This is useful in situations were the device requires a one-off value different from the rest of the environment. +* Region +* Site +* Role +* Platform +* Cluster group +* Cluster +* Tenant group +* Tenant +* Tag + +## Hierarchical Rendering + +Context data is arranged hierarchically, so that data with a higher weight can be entered to override lower-weight data. Multiple instances of data are automatically merged by NetBox to present a single dictionary for each object. + +For example, suppose we want to specify a set of syslog and NTP servers for all devices within a region. We could create a config context instance with a weight of 1000 assigned to the region, with the following JSON data: + +```json +{ + "ntp-servers": [ + "172.16.10.22", + "172.16.10.33" + ], + "syslog-servers": [ + "172.16.9.100", + "172.16.9.101" + ] +} +``` + +But suppose there's a problem at one particular site within this region preventing traffic from reaching the regional syslog server. Devices there need to use a local syslog server instead of the two defined above. We'll create a second config context assigned only to that site with a weight of 2000 and the following data: + +```json +{ + "syslog-servers": [ + "192.168.43.107" + ] +} +``` + +When the context data for a device at this site is rendered, the second, higher-weight data overwrite the first, resulting in the following: + +```json +{ + "ntp-servers": [ + "172.16.10.22", + "172.16.10.33" + ], + "syslog-servers": [ + "192.168.43.107" + ] +} +``` + +Data from the higher-weight context overwrites conflicting data from the lower-weight context, while the non-conflicting portion of the lower-weight context (the list of NTP servers) is preserved. + +## Local Context Data + +Devices and virtual machines may also have a local config context defined. This local context will _always_ take precedence over any separate config context objects which apply to the device/VM. This is useful in situations where we need to call out a specific deviation in the data for a particular object. + +!!! warning + If you find that you're routinely defining local context data for many individual devices or virtual machines, custom fields may offer a more effective solution. diff --git a/docs/models/extras/imageattachment.md b/docs/models/extras/imageattachment.md new file mode 100644 index 000000000..da15462ab --- /dev/null +++ b/docs/models/extras/imageattachment.md @@ -0,0 +1,3 @@ +# Image Attachments + +Certain objects in NetBox support the attachment of uploaded images. These will be saved to the NetBox server and made available whenever the object is viewed. diff --git a/docs/models/extras/tag.md b/docs/models/extras/tag.md index f94957616..29cc8b757 100644 --- a/docs/models/extras/tag.md +++ b/docs/models/extras/tag.md @@ -1,24 +1,20 @@ # 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 (for example: `Inventoried, Monitored`). Use double quotes around a multi-word tag when adding only one tag, e.g. `"Core Switch"`. +Tags are user-defined labels which can be applied to a variety of objects within NetBox. They can be used to establish dimensions of organization beyond the relationships built into NetBox. For example, you might create a tag to identify a particular ownership or condition across several types of objects. -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. +Each tag has a label, color, 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. Each tag can also be assigned a description indicating its purpose. Objects can be filtered by the tags they have applied. For example, the following API request will retrieve all devices tagged as "monitored": -``` +```no-highlight GET /api/dcim/devices/?tag=monitored ``` -Tags are included in the API representation of an object as a list of plain strings: +The `tag` filter can be specified multiple times to match only objects which have _all_ of the specified tags assigned: +```no-highlight +GET /api/dcim/devices/?tag=monitored&tag=deprecated ``` -{ - ... - "tags": [ - "Core Switch", - "Monitored" - ], - ... -} -``` + +!!! note + Tags have changed substantially in NetBox v2.9. They are no longer created on-demand when editing an object, and their representation in the REST API now includes a complete depiction of the tag rather than only its label. diff --git a/docs/models/ipam/aggregate.md b/docs/models/ipam/aggregate.md index f43209619..ff5a50a39 100644 --- a/docs/models/ipam/aggregate.md +++ b/docs/models/ipam/aggregate.md @@ -1,6 +1,18 @@ # Aggregates -The first step to documenting your IP space is to define its scope by creating aggregates. Aggregates establish the root of your IP address hierarchy by defining the top-level allocations that you're interested in managing. Most organizations will want to track some commonly-used private IP spaces, such as: +IP addressing is by nature hierarchical. The first few levels of the IPv4 hierarchy, for example, look like this: + +* 0.0.0.0/0 + * 0.0.0.0/1 + * 0.0.0.0/2 + * 64.0.0.0/2 + * 128.0.0.0/1 + * 128.0.0.0/2 + * 192.0.0.0/2 + +This hierarchy comprises 33 tiers of addressing, from /0 all the way down to individual /32 address (and much, much further to /128 for IPv6). Of course, most organizations are concerned with only relatively small portions of the total IP space, so tracking the uppermost of these tiers isn't necessary. + +NetBox allows us to specify the portions of IP space that are interesting to us by defining _aggregates_. Typically, an aggregate will correspond to either an allocation of public (globally routable) IP space granted by a regional authority, or a private (internally-routable) designation. Common private designations include: * 10.0.0.0/8 (RFC 1918) * 100.64.0.0/10 (RFC 6598) @@ -8,8 +20,9 @@ The first step to documenting your IP space is to define its scope by creating a * 192.168.0.0/16 (RFC 1918) * One or more /48s within fd00::/8 (IPv6 unique local addressing) -In addition to one or more of these, you'll want to create an aggregate for each globally-routable space your organization has been allocated. These aggregates should match the allocations recorded in public WHOIS databases. +Each aggregate is assigned to a RIR. For "public" aggregates, this will be the real-world authority which has granted your organization permission to use the specified IP space on the public Internet. For "private" aggregates, this will be a statutory authority, such as RFC 1918. Each aggregate can also annotate that date on which it was allocated, where applicable. -Each IP prefix will be automatically arranged under its parent aggregate if one exists. Note that it's advised to create aggregates only for IP ranges actually allocated to your organization (or marked for private use): There is no need to define aggregates for provider-assigned space which is only used on Internet circuits, for example. +Prefixes are automatically arranged beneath their parent aggregates in NetBox. Typically you'll want to create aggregates only for the prefixes and IP addresses that your organization actually manages: There is no need to define aggregates for provider-assigned space which is only used on Internet circuits, for example. -Aggregates cannot overlap with one another: They can only exist side-by-side. For instance, you cannot define both 10.0.0.0/8 and 10.16.0.0/16 as aggregates, because they overlap. 10.16.0.0/16 in this example would be created as a prefix and automatically grouped under 10.0.0.0/8. Remember, the purpose of aggregates is to establish the root of your IP addressing hierarchy. +!!! note + Because aggregates represent swaths of the global IP space, they cannot overlap with one another: They can only exist side-by-side. For instance, you cannot define both 10.0.0.0/8 and 10.16.0.0/16 as aggregates, because they overlap. 10.16.0.0/16 in this example would be created as a container prefix and automatically grouped under the 10.0.0.0/8 aggregate. Remember, the purpose of aggregates is to establish the root of your IP addressing hierarchy. diff --git a/docs/models/ipam/ipaddress.md b/docs/models/ipam/ipaddress.md index cbe12553d..1ea613997 100644 --- a/docs/models/ipam/ipaddress.md +++ b/docs/models/ipam/ipaddress.md @@ -2,16 +2,17 @@ An IP address comprises a single host address (either IPv4 or IPv6) and its subnet mask. Its mask should match exactly how the IP address is configured on an interface in the real world. -Like prefixes, an IP address can optionally be assigned to a VRF (otherwise, it will appear in the "global" table). IP addresses are automatically organized under parent prefixes within their respective VRFs. +Like a prefix, an IP address can optionally be assigned to a VRF (otherwise, it will appear in the "global" table). IP addresses are automatically arranged under parent prefixes within their respective VRFs according to the IP hierarchy. -Also like prefixes, each IP address can be assigned a status and a role. Statuses are hard-coded in NetBox and include the following: +Each IP address can also be assigned an operational status and a functional role. Statuses are hard-coded in NetBox and include the following: * Active * Reserved * Deprecated * DHCP +* SLAAC (IPv6 Stateless Address Autoconfiguration) -Each IP address can optionally be assigned a special role. Roles are used to indicate some special attribute of an IP address: for example, it is used as a loopback, or is a virtual IP maintained using VRRP. (Note that this differs in purpose from a _functional_ role, and thus cannot be customized.) Available roles include: +Roles are used to indicate some special attribute of an IP address; for example, use as a loopback or as the the virtual IP for a VRRP group. (Note that functional roles are conceptual in nature, and thus cannot be customized by the user.) Available roles include: * Loopback * Secondary @@ -21,7 +22,10 @@ Each IP address can optionally be assigned a special role. Roles are used to ind * HSRP * GLBP -An IP address can be assigned to a device or virtual machine interface, and an interface may have multiple IP addresses assigned to it. Further, each device and virtual machine may have one of its interface IPs designated as its primary IP address (one for IPv4 and one for IPv6). +An IP address can be assigned to any device or virtual machine interface, and an interface may have multiple IP addresses assigned to it. Further, each device and virtual machine may have one of its interface IPs designated as its primary IP per address family (one for IPv4 and one for IPv6). + +!!! note + When primary IPs are set for both IPv4 and IPv6, NetBox will prefer IPv6. This can be changed by setting the `PREFER_IPV4` configuration parameter. ## Network Address Translation (NAT) diff --git a/docs/models/ipam/prefix.md b/docs/models/ipam/prefix.md index 9ab5382a5..bd5e9695f 100644 --- a/docs/models/ipam/prefix.md +++ b/docs/models/ipam/prefix.md @@ -2,7 +2,7 @@ A prefix is an IPv4 or IPv6 network and mask expressed in CIDR notation (e.g. 192.0.2.0/24). A prefix entails only the "network portion" of an IP address: All bits in the address not covered by the mask must be zero. (In other words, a prefix cannot be a specific IP address.) -Prefixes are automatically arranged by their parent aggregates. Additionally, each prefix can be assigned to a particular site and VRF (routing table). All prefixes not assigned to a VRF will appear in the "global" table. +Prefixes are automatically organized by their parent aggregates. Additionally, each prefix can be assigned to a particular site and virtual routing and forwarding instance (VRF). Each VRF represents a separate IP space or routing table. All prefixes not assigned to a VRF are considered to be in the "global" table. Each prefix can be assigned a status and a role. These terms are often used interchangeably so it's important to recognize the difference between them. The **status** defines a prefix's operational state. Statuses are hard-coded in NetBox and can be one of the following: @@ -13,6 +13,6 @@ Each prefix can be assigned a status and a role. These terms are often used inte On the other hand, a prefix's **role** defines its function. Role assignment is optional and roles are fully customizable. For example, you might create roles to differentiate between production and development infrastructure. -A prefix may also be assigned to a VLAN. This association is helpful for identifying which prefixes are included when reviewing a list of VLANs. +A prefix may also be assigned to a VLAN. This association is helpful for associating address space with layer two domains. A VLAN may have multiple prefixes assigned to it. -The prefix model include a "pool" flag. If enabled, NetBox will treat this prefix as a range (such as a NAT pool) wherein every IP address is valid and assignable. This logic is used for identifying available IP addresses within a prefix. If this flag is disabled, NetBox will assume that the first and last (broadcast) address within the prefix are unusable. +The prefix model include an "is pool" flag. If enabled, NetBox will treat this prefix as a range (such as a NAT pool) wherein every IP address is valid and assignable. This logic is used when identifying available IP addresses within a prefix. If this flag is disabled, NetBox will assume that the first and last (broadcast) address within an IPv4 prefix are unusable. diff --git a/docs/models/ipam/rir.md b/docs/models/ipam/rir.md index 69c34e72d..6904381ac 100644 --- a/docs/models/ipam/rir.md +++ b/docs/models/ipam/rir.md @@ -1,7 +1,7 @@ # Regional Internet Registries (RIRs) -[Regional Internet registries](https://en.wikipedia.org/wiki/Regional_Internet_registry) are responsible for the allocation of globally-routable address space. The five RIRs are ARIN, RIPE, APNIC, LACNIC, and AFRINIC. However, some address space has been set aside for internal use, such as defined in RFCs 1918 and 6598. NetBox considers these RFCs as a sort of RIR as well; that is, an authority which "owns" certain address space. There also exist lower-tier registries which serve a particular geographic area. +[Regional Internet registries](https://en.wikipedia.org/wiki/Regional_Internet_registry) are responsible for the allocation of globally-routable address space. The five RIRs are ARIN, RIPE, APNIC, LACNIC, and AFRINIC. However, some address space has been set aside for internal use, such as defined in RFCs 1918 and 6598. NetBox considers these RFCs as a sort of RIR as well; that is, an authority which "owns" certain address space. There also exist lower-tier registries which serve particular geographic areas. -Each aggregate must be assigned to one RIR. You are free to define whichever RIRs you choose (or create your own). The RIR model includes a boolean flag which indicates whether the RIR allocates only private IP space. +Users can create whatever RIRs they like, but each aggregate must be assigned to one RIR. The RIR model includes a boolean flag which indicates whether the RIR allocates only private IP space. -For example, suppose your organization has been allocated 104.131.0.0/16 by ARIN. It also makes use of RFC 1918 addressing internally. You would first create RIRs named ARIN and RFC 1918, then create an aggregate for each of these top-level prefixes, assigning it to its respective RIR. +For example, suppose your organization has been allocated 104.131.0.0/16 by ARIN. It also makes use of RFC 1918 addressing internally. You would first create RIRs named "ARIN" and "RFC 1918," then create an aggregate for each of these top-level prefixes, assigning it to its respective RIR. diff --git a/docs/models/ipam/routetarget.md b/docs/models/ipam/routetarget.md new file mode 100644 index 000000000..b71e96904 --- /dev/null +++ b/docs/models/ipam/routetarget.md @@ -0,0 +1,5 @@ +# Route Targets + +A route target is a particular type of [extended BGP community](https://tools.ietf.org/html/rfc4360#section-4) used to control the redistribution of routes among VRF tables in a network. Route targets can be assigned to individual VRFs in NetBox as import or export targets (or both) to model this exchange in an L3VPN. Each route target must be given a unique name, which should be in a format prescribed by [RFC 4364](https://tools.ietf.org/html/rfc4364#section-4.2), similar to a VR route distinguisher. + +Each route target can optionally be assigned to a tenant, and may have tags assigned to it. diff --git a/docs/models/ipam/vlan.md b/docs/models/ipam/vlan.md index 48f24006c..f252204c5 100644 --- a/docs/models/ipam/vlan.md +++ b/docs/models/ipam/vlan.md @@ -1,6 +1,6 @@ # VLANs -A VLAN represents an isolated layer two domain, identified by a name and a numeric ID (1-4094) as defined in [IEEE 802.1Q](https://en.wikipedia.org/wiki/IEEE_802.1Q). Each VLAN may be assigned to a site and/or VLAN group. +A VLAN represents an isolated layer two domain, identified by a name and a numeric ID (1-4094) as defined in [IEEE 802.1Q](https://en.wikipedia.org/wiki/IEEE_802.1Q). Each VLAN may be assigned to a site, tenant, and/or VLAN group. Each VLAN must be assigned one of the following operational statuses: @@ -8,4 +8,4 @@ Each VLAN must be assigned one of the following operational statuses: * Reserved * Deprecated -Each VLAN may also be assigned a functional role. Prefixes and VLANs share the same set of customizable roles. +As with prefixes, each VLAN may also be assigned a functional role. Prefixes and VLANs share the same set of customizable roles. diff --git a/docs/models/ipam/vlangroup.md b/docs/models/ipam/vlangroup.md index 1fa31c522..7a0bb80ff 100644 --- a/docs/models/ipam/vlangroup.md +++ b/docs/models/ipam/vlangroup.md @@ -1,3 +1,5 @@ # VLAN Groups -VLAN groups can be used to organize VLANs within NetBox. Groups can also be used to enforce uniqueness: Each VLAN within a group must have a unique ID and name. VLANs which are not assigned to a group may have overlapping names and IDs (including VLANs which belong to a common site). For example, you can create two VLANs with ID 123, but they cannot both be assigned to the same group. +VLAN groups can be used to organize VLANs within NetBox. Each group may optionally be assigned to a specific site, but a group cannot belong to multiple sites. + +Groups can also be used to enforce uniqueness: Each VLAN within a group must have a unique ID and name. VLANs which are not assigned to a group may have overlapping names and IDs (including VLANs which belong to a common site). For example, you can create two VLANs with ID 123, but they cannot both be assigned to the same group. diff --git a/docs/models/ipam/vrf.md b/docs/models/ipam/vrf.md index c3d3390e4..392141fdd 100644 --- a/docs/models/ipam/vrf.md +++ b/docs/models/ipam/vrf.md @@ -1,12 +1,14 @@ # Virtual Routing and Forwarding (VRF) -A VRF object in NetBox represents a virtual routing and forwarding (VRF) domain. Each VRF is essentially a separate routing table. VRFs are commonly used to isolate customers or organizations from one another within a network, or to route overlapping address space (e.g. multiple instances of the 10.0.0.0/8 space). +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 may be assigned to a specific tenant to aid in organizing the available IP space by customer or internal user. 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. +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 prefix or IP address not assigned to a VRF is said to belong to the "global" table. -By default, NetBox will allow duplicate prefixes to be assigned to a VRF. This behavior can be disabled by setting the "enforce unique" flag on the VRF model. +By default, NetBox will allow duplicate prefixes to be assigned to a VRF. This behavior can be toggled by setting the "enforce unique" flag on the VRF model. !!! note Enforcement of unique IP space can be toggled for global table (non-VRF prefixes) using the `ENFORCE_GLOBAL_UNIQUE` configuration setting. + +Each VRF may have one or more import and/or export route targets applied to it. Route targets are used to control the exchange of routes (prefixes) among VRFs in L3VPNs. diff --git a/docs/models/secrets/secretrole.md b/docs/models/secrets/secretrole.md index 8997ed52a..23f68912b 100644 --- a/docs/models/secrets/secretrole.md +++ b/docs/models/secrets/secretrole.md @@ -7,5 +7,3 @@ Each secret is assigned a functional role which indicates what it is used for. S * RADIUS/TACACS+ keys * IKE key strings * Routing protocol shared secrets - -Roles are also used to control access to secrets. Each role is assigned an arbitrary number of groups and/or users. Only the users associated with a role have permission to decrypt the secrets assigned to that role. (A superuser has permission to decrypt all secrets, provided they have an active user key.) diff --git a/docs/models/tenancy/tenant.md b/docs/models/tenancy/tenant.md index f7cf68ab8..60a160b9e 100644 --- a/docs/models/tenancy/tenant.md +++ b/docs/models/tenancy/tenant.md @@ -1,6 +1,6 @@ # Tenants -A tenant represents a discrete entity for administrative purposes. Typically, tenants are used to represent individual customers or internal departments within an organization. The following objects can be assigned to tenants: +A tenant represents a discrete grouping of resources used for administrative purposes. Typically, tenants are used to represent individual customers or internal departments within an organization. The following objects can be assigned to tenants: * Sites * Racks @@ -11,6 +11,7 @@ A tenant represents a discrete entity for administrative purposes. Typically, te * IP addresses * VLANs * Circuits +* Clusters * Virtual machines -Tenant assignment is used to signify ownership of an object in NetBox. As such, each object may only be owned by a single tenant. For example, if you have a firewall dedicated to a particular customer, you would assign it to the tenant which represents that customer. However, if the firewall serves multiple customers, it doesn't *belong* to any particular customer, so tenant assignment would not be appropriate. +Tenant assignment is used to signify the ownership of an object in NetBox. As such, each object may only be owned by a single tenant. For example, if you have a firewall dedicated to a particular customer, you would assign it to the tenant which represents that customer. However, if the firewall serves multiple customers, it doesn't *belong* to any particular customer, so tenant assignment would not be appropriate. diff --git a/docs/models/tenancy/tenantgroup.md b/docs/models/tenancy/tenantgroup.md index a2ed7e324..078a71a72 100644 --- a/docs/models/tenancy/tenantgroup.md +++ b/docs/models/tenancy/tenantgroup.md @@ -1,5 +1,5 @@ # Tenant Groups -Tenants can be organized by custom groups. For instance, you might create one group called "Customers" and one called "Acquisitions." The assignment of tenants to groups is optional. +Tenants can be organized by custom groups. For instance, you might create one group called "Customers" and one called "Departments." The assignment of a tenant to a group is optional. -Tenant groups may be nested to achieve a multi-level hierarchy. For example, you might have a group called "Customers" containing subgroups of individual tenants grouped by product or account team. +Tenant groups may be nested recursively to achieve a multi-level hierarchy. For example, you might have a group called "Customers" containing subgroups of individual tenants grouped by product or account team. diff --git a/docs/models/users/objectpermission.md b/docs/models/users/objectpermission.md new file mode 100644 index 000000000..48970dd05 --- /dev/null +++ b/docs/models/users/objectpermission.md @@ -0,0 +1,55 @@ +# Object Permissions + +A permission in NetBox represents a relationship shared by several components: + +* Object type(s) - One or more types of object in NetBox +* User(s)/Group(s) - One or more users or groups of users +* Action(s) - The action(s) that can be performed on an object +* Constraints - An arbitrary filter used to limit the granted action(s) to a specific subset of objects + +At a minimum, a permission assignment must specify one object type, one user or group, and one action. The specification of constraints is optional: A permission without any constraints specified will apply to all instances of the selected model(s). + +## Actions + +There are four core actions that can be permitted for each type of object within NetBox, roughly analogous to the CRUD convention (create, read, update, and delete): + +* **View** - Retrieve an object from the database +* **Add** - Create a new object +* **Change** - Modify an existing object +* **Delete** - Delete an existing object + +In addition to these, permissions can also grant custom actions that may be required by a specific model or plugin. For example, the `napalm_read` permission on the device model allows a user to execute NAPALM queries on a device via NetBox's REST API. These can be specified when granting a permission in the "additional actions" field. + +!!! note + Internally, all actions granted by a permission (both built-in and custom) are stored as strings in an array field named `actions`. + +## Constraints + +Constraints are expressed as a JSON object or list representing a [Django query filter](https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups). This is the same syntax that you would pass to the QuerySet `filter()` method when performing a query using the Django ORM. As with query filters, double underscores can be used to traverse related objects or invoke lookup expressions. Some example queries and their corresponding definitions are shown below. + +All attributes defined within a single JSON object are applied with a logical AND. For example, suppose you assign a permission for the site model with the following constraints. + +```json +{ + "status": "active", + "region__name": "Americas" +} +``` + +The permission will grant access only to sites which have a status of "active" **and** which are assigned to the "Americas" region. + +To achieve a logical OR with a different set of constraints, define multiple objects within a list. For example, if you want to constrain the permission to VLANs with an ID between 100 and 199 _or_ a status of "reserved," do the following: + +```json +[ + { + "vid__gte": 100, + "vid__lt": 200 + }, + { + "status": "reserved" + } +] +``` + +Additionally, where multiple permissions have been assigned for an object type, their collective constraints will be merged using a logical "OR" operation. diff --git a/docs/models/users/token.md b/docs/models/users/token.md index bbeb2284b..d0e0f8609 100644 --- a/docs/models/users/token.md +++ b/docs/models/users/token.md @@ -1,12 +1,12 @@ ## Tokens -A token is a unique identifier that identifies a user to the API. Each user in NetBox may have one or more tokens which he or she can use to authenticate to the API. To create a token, navigate to the API tokens page at `/user/api-tokens/`. +A token is a unique identifier mapped to a NetBox user account. Each user may have one or more tokens which he or she can use for authentication when making REST API requests. To create a token, navigate to the API tokens page under your user profile. !!! note The creation and modification of API tokens can be restricted per user by an administrator. If you don't see an option to create an API token, ask an administrator to grant you access. Each token contains a 160-bit key represented as 40 hexadecimal characters. When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation. -By default, a token can be used for all operations available via the API. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only. +By default, a token can be used to perform all actions via the API that a user would be permitted to do via the web UI. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only. Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox. diff --git a/docs/models/virtualization/cluster.md b/docs/models/virtualization/cluster.md index 6d8ce4214..3311ad42d 100644 --- a/docs/models/virtualization/cluster.md +++ b/docs/models/virtualization/cluster.md @@ -1,5 +1,5 @@ # Clusters -A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type, and may optionally be assigned to a group and/or site. +A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type (technological classification), and may optionally be assigned to a cluster group, site, and/or tenant. -Physical devices may be associated with clusters as hosts. This allows users to track on which host(s) a particular VM may reside. However, NetBox does not support pinning a specific VM within a cluster to a particular host device. +Physical devices may be associated with clusters as hosts. This allows users to track on which host(s) a particular virtual machine may reside. However, NetBox does not support pinning a specific VM within a cluster to a particular host device. diff --git a/docs/models/virtualization/clustergroup.md b/docs/models/virtualization/clustergroup.md index 9e1e17315..6dd0f9688 100644 --- a/docs/models/virtualization/clustergroup.md +++ b/docs/models/virtualization/clustergroup.md @@ -1,3 +1,3 @@ # Cluster Groups -Cluster groups may be created for the purpose of organizing clusters. The assignment of clusters to groups is optional. +Cluster groups may be created for the purpose of organizing clusters. The arrangement of clusters into groups is optional. diff --git a/docs/models/virtualization/virtualmachine.md b/docs/models/virtualization/virtualmachine.md index 5a82f8267..40e9ef2c0 100644 --- a/docs/models/virtualization/virtualmachine.md +++ b/docs/models/virtualization/virtualmachine.md @@ -1,11 +1,14 @@ # Virtual Machines -A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be associated with exactly one cluster. +A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be assigned to exactly one cluster. -Like devices, each VM can be assigned a platform and have interfaces created on it. VM interfaces behave similarly to device interfaces, and can be assigned IP addresses, VLANs, and services. However, given their virtual nature, they cannot be connected to other interfaces. Unlike physical devices, VMs cannot be assigned console or power ports, device bays, or inventory items. +Like devices, each VM can be assigned a platform and/or functional role, and must have one of the following operational statuses assigned to it: -The following resources can be defined for each VM: +* Active +* Offline +* Planned +* Staged +* Failed +* Decommissioning -* vCPU count -* Memory (MB) -* Disk space (GB) +Additional fields are available for annotating the vCPU count, memory (GB), and disk (GB) allocated to each VM. Each VM may optionally be assigned to a tenant. Virtual machines may have virtual interfaces assigned to them, but do not support any physical component. diff --git a/docs/models/virtualization/vminterface.md b/docs/models/virtualization/vminterface.md new file mode 100644 index 000000000..6fac7ce36 --- /dev/null +++ b/docs/models/virtualization/vminterface.md @@ -0,0 +1,3 @@ +## Interfaces + +Virtual machine interfaces behave similarly to device interfaces, and can be assigned IP addresses, VLANs, and services. However, given their virtual nature, they lack properties pertaining to physical attributes. For example, VM interfaces do not have a physical type and cannot have cables attached to them. diff --git a/docs/plugins/development.md b/docs/plugins/development.md index ad7eef310..f008da2fb 100644 --- a/docs/plugins/development.md +++ b/docs/plugins/development.md @@ -12,6 +12,9 @@ Plugins can do a lot, including: However, keep in mind that each piece of functionality is entirely optional. For example, if your plugin merely adds a piece of middleware or an API endpoint for existing data, there's no need to define any new models. +!!! warning + While very powerful, the NetBox plugins API is necessarily limited in its scope. The plugins API is discussed here in its entirety: Any part of the NetBox code base not documented here is _not_ part of the supported plugins API, and should not be employed by a plugin. Internal elements of NetBox are subject to change at any time and without warning. Plugin authors are **strongly** encouraged to develop plugins using only the officially supported components discussed here and those provided by the underlying Django framework so as to avoid breaking changes in future releases. + ## Initial Setup ## Plugin Structure @@ -60,11 +63,15 @@ setup( install_requires=[], packages=find_packages(), include_package_data=True, + zip_safe=False, ) ``` Many of these are self-explanatory, but for more information, see the [setuptools documentation](https://setuptools.readthedocs.io/en/latest/setuptools.html). +!!! note + `zip_safe=False` is **required** as the current plugin iteration is not zip safe due to upstream python issue [issue19699](https://bugs.python.org/issue19699) + ### Define a PluginConfig The `PluginConfig` class is a NetBox-specific wrapper around Django's built-in [`AppConfig`](https://docs.djangoproject.com/en/stable/ref/applications/) class. It is used to declare NetBox plugin functionality within a Python package. Each plugin should provide its own subclass, defining its name, metadata, and default and required configuration parameters. An example is below: @@ -110,6 +117,8 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i | `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) | | `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) | +All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored. + ### Install the Plugin for Development To ease development, it is recommended to go ahead and install the plugin at this point using setuptools' `develop` mode. This will create symbolic links within your Python environment to the plugin development directory. Call `setup.py` from the plugin's root directory with the `develop` argument (instead of `install`): @@ -196,26 +205,37 @@ class RandomAnimalView(View): }) ``` -This view retrieves a random animal from the database and and passes it as a context variable when rendering a template named `animal.html`, which doesn't exist yet. To create this template, first create a directory named `templates/netbox_animal_sounds/` within the plugin source directory. (We use the plugin's name as a subdirectory to guard against naming collisions with other plugins.) Then, create `animal.html`: +This view retrieves a random animal from the database and and passes it as a context variable when rendering a template named `animal.html`, which doesn't exist yet. To create this template, first create a directory named `templates/netbox_animal_sounds/` within the plugin source directory. (We use the plugin's name as a subdirectory to guard against naming collisions with other plugins.) Then, create a template named `animal.html` as described below. + +### Extending the Base Template + +NetBox provides a base template to ensure a consistent user experience, which plugins can extend with their own content. This template includes four content blocks: + +* `title` - The page title +* `header` - The upper portion of the page +* `content` - The main page body +* `javascript` - A section at the end of the page for including Javascript code + +For more information on how template blocks work, consult the [Django documentation](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#block). ```jinja2 {% extends 'base.html' %} {% block content %} -{% with config=settings.PLUGINS_CONFIG.netbox_animal_sounds %} -

- {% if animal %} - The {{ animal.name|lower }} says - {% if config.loud %} - {{ animal.sound|upper }}! - {% else %} - {{ animal.sound }} - {% endif %} - {% else %} - No animals have been created yet! - {% endif %} -

-{% endwith %} + {% with config=settings.PLUGINS_CONFIG.netbox_animal_sounds %} +

+ {% if animal %} + The {{ animal.name|lower }} says + {% if config.loud %} + {{ animal.sound|upper }}! + {% else %} + {{ animal.sound }} + {% endif %} + {% else %} + No animals have been created yet! + {% endif %} +

+ {% endwith %} {% endblock %} ``` @@ -326,6 +346,9 @@ A `PluginMenuButton` has the following attributes: * `color` - One of the choices provided by `ButtonColorChoices` (optional) * `permissions` - A list of permissions required to display this button (optional) +!!! note + Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons. + ## Extending Core Templates Plugins can inject custom content into certain areas of the detail views of applicable models. This is accomplished by subclassing `PluginTemplateExtension`, designating a particular NetBox model, and defining the desired methods to render custom content. Four methods are available: diff --git a/docs/plugins/index.md b/docs/plugins/index.md index 1f5587539..202e0a96b 100644 --- a/docs/plugins/index.md +++ b/docs/plugins/index.md @@ -64,6 +64,15 @@ PLUGINS_CONFIG = { } ``` +### Run Database Migrations + +If the plugin introduces new database models, run the provided schema migrations: + +```no-highlight +(venv) $ cd /opt/netbox/netbox/ +(venv) $ python3 manage.py migrate +``` + ### Collect Static Files Plugins may package static files to be served directly by the HTTP front end. Ensure that these are copied to the static root directory with the `collectstatic` management command: diff --git a/docs/release-notes/index.md b/docs/release-notes/index.md index 364b2cd9d..8990f83e0 120000 --- a/docs/release-notes/index.md +++ b/docs/release-notes/index.md @@ -1 +1 @@ -version-2.8.md \ No newline at end of file +version-2.10.md \ No newline at end of file diff --git a/docs/release-notes/version-2.1.md b/docs/release-notes/version-2.1.md index 59f23c090..e5fa41d82 100644 --- a/docs/release-notes/version-2.1.md +++ b/docs/release-notes/version-2.1.md @@ -121,7 +121,7 @@ A new API endpoint has been added at `/api/ipam/prefixes//available-ips/`. A #### NAPALM Integration ([#1348](https://github.com/netbox-community/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. +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](https://netbox.readthedocs.io/en/stable/configuration/optional-settings/#netbox_username) have been set in configuration.py. ### Enhancements diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md new file mode 100644 index 000000000..a3ab5968c --- /dev/null +++ b/docs/release-notes/version-2.10.md @@ -0,0 +1,210 @@ +# NetBox v2.10 + +## v2.10.3 (2021-01-05) + +### Bug Fixes + +* [#5049](https://github.com/netbox-community/netbox/issues/5049) - Add check for LLDP neighbor chassis name to lldp_neighbors +* [#5301](https://github.com/netbox-community/netbox/issues/5301) - Fix misleading error when racking a device with invalid parameters +* [#5311](https://github.com/netbox-community/netbox/issues/5311) - Update child objects when a rack group is moved to a new site +* [#5518](https://github.com/netbox-community/netbox/issues/5518) - Fix persistent vertical scrollbar +* [#5533](https://github.com/netbox-community/netbox/issues/5533) - Fix bulk editing of objects with required custom fields +* [#5540](https://github.com/netbox-community/netbox/issues/5540) - Fix exception when viewing a provider with one or more tags assigned +* [#5543](https://github.com/netbox-community/netbox/issues/5543) - Fix rendering of config contexts with cluster assignment for devices +* [#5546](https://github.com/netbox-community/netbox/issues/5546) - Add custom field bulk edit support for cables, power panels, rack reservations, and virtual chassis +* [#5547](https://github.com/netbox-community/netbox/issues/5547) - Add custom field bulk import support for cables, power panels, rack reservations, and virtual chassis +* [#5551](https://github.com/netbox-community/netbox/issues/5551) - Restore missing import button on services list +* [#5557](https://github.com/netbox-community/netbox/issues/5557) - Fix VRF route target assignment via REST API +* [#5558](https://github.com/netbox-community/netbox/issues/5558) - Fix regex validation support for custom URL fields +* [#5563](https://github.com/netbox-community/netbox/issues/5563) - Fix power feed cable trace link +* [#5564](https://github.com/netbox-community/netbox/issues/5564) - Raise validation error if a power port template's `allocated_draw` exceeds its `maximum_draw` +* [#5569](https://github.com/netbox-community/netbox/issues/5569) - Ensure consistent labeling of interface `mgmt_only` field +* [#5573](https://github.com/netbox-community/netbox/issues/5573) - Report inconsistent values when migrating custom field data + +--- + +## v2.10.2 (2020-12-21) + +### Enhancements + +* [#5489](https://github.com/netbox-community/netbox/issues/5489) - Add filters for type and width to racks list +* [#5496](https://github.com/netbox-community/netbox/issues/5496) - Add form field to filter rack reservation by user + +### Bug Fixes + +* [#5254](https://github.com/netbox-community/netbox/issues/5254) - Require plugin authors to set zip_safe=False +* [#5468](https://github.com/netbox-community/netbox/issues/5468) - Fix unlocking secrets from device/VM view +* [#5473](https://github.com/netbox-community/netbox/issues/5473) - Fix alignment of rack names in elevations list +* [#5478](https://github.com/netbox-community/netbox/issues/5478) - Fix display of route target description +* [#5484](https://github.com/netbox-community/netbox/issues/5484) - Fix "tagged" indication in VLAN members list +* [#5486](https://github.com/netbox-community/netbox/issues/5486) - Optimize retrieval of config context data for device/VM REST API views +* [#5487](https://github.com/netbox-community/netbox/issues/5487) - Support filtering rack type/width with multiple values +* [#5488](https://github.com/netbox-community/netbox/issues/5488) - Fix caching error when viewing cable trace after toggling cable status +* [#5498](https://github.com/netbox-community/netbox/issues/5498) - Fix filtering rack reservations by username +* [#5499](https://github.com/netbox-community/netbox/issues/5499) - Fix filtering of displayed device/VM interfaces by regex +* [#5507](https://github.com/netbox-community/netbox/issues/5507) - Fix custom field data assignment via UI for IP addresses, secrets +* [#5510](https://github.com/netbox-community/netbox/issues/5510) - Fix filtering by boolean custom fields + +--- + +## v2.10.1 (2020-12-15) + +### Bug Fixes + +* [#5444](https://github.com/netbox-community/netbox/issues/5444) - Don't force overwriting of boolean fields when bulk editing interfaces +* [#5450](https://github.com/netbox-community/netbox/issues/5450) - API serializer foreign count fields do not have a default value +* [#5453](https://github.com/netbox-community/netbox/issues/5453) - Correct change log representation when creating a cable +* [#5458](https://github.com/netbox-community/netbox/issues/5458) - Creating a component template throws an exception +* [#5461](https://github.com/netbox-community/netbox/issues/5461) - Rack Elevations throw reverse match exception +* [#5463](https://github.com/netbox-community/netbox/issues/5463) - Back-to-back Circuit Termination throws AttributeError exception +* [#5465](https://github.com/netbox-community/netbox/issues/5465) - Correct return URL when disconnecting a cable from a device +* [#5466](https://github.com/netbox-community/netbox/issues/5466) - Fix validation for required custom fields +* [#5470](https://github.com/netbox-community/netbox/issues/5470) - Fix exception when making `OPTIONS` request for a REST API list endpoint + +--- + +## v2.10.0 (2020-12-14) + +**NOTE:** This release completely removes support for embedded graphs. + +**NOTE:** The Django templating language (DTL) is no longer supported for export templates. Ensure that all export templates use Jinja2 before upgrading. + +### New Features + +#### Route Targets ([#259](https://github.com/netbox-community/netbox/issues/259)) + +This release introduces support for modeling L3VPN route targets, which can be used to control the redistribution of advertised prefixes among VRFs. Each VRF may be assigned one or more route targets in the import and/or export direction. Like VRFs, route targets may be assigned to tenants and support tag assignment. + +#### REST API Bulk Deletion ([#3436](https://github.com/netbox-community/netbox/issues/3436)) + +The REST API now supports the bulk deletion of objects of the same type in a single request. Send a `DELETE` HTTP request to the list to the model's list endpoint (e.g. `/api/dcim/sites/`) with a list of JSON objects specifying the numeric ID of each object to be deleted. For example, to delete sites with IDs 10, 11, and 12, issue the following request: + +```no-highlight +curl -s -X DELETE \ +-H "Authorization: Token $TOKEN" \ +-H "Content-Type: application/json" \ +http://netbox/api/dcim/sites/ \ +--data '[{"id": 10}, {"id": 11}, {"id": 12}]' +``` + +#### REST API Bulk Update ([#4882](https://github.com/netbox-community/netbox/issues/4882)) + +Similar to bulk deletion, the REST API also now supports bulk updates. Send a `PUT` or `PATCH` HTTP request to the list to the model's list endpoint (e.g. `/api/dcim/sites/`) with a list of JSON objects specifying the numeric ID of each object and the attribute(s) to be updated. For example, to set a description for sites with IDs 10 and 11, issue the following request: + +```no-highlight +curl -s -X PATCH \ +-H "Authorization: Token $TOKEN" \ +-H "Content-Type: application/json" \ +http://netbox/api/dcim/sites/ \ +--data '[{"id": 10, "description": "Foo"}, {"id": 11, "description": "Bar"}]' +``` + +#### Reimplementation of Custom Fields ([#4878](https://github.com/netbox-community/netbox/issues/4878)) + +NetBox v2.10 introduces a completely overhauled approach to custom fields. Whereas previous versions used CustomFieldValue instances to store values, custom field data is now stored directly on each model instance as JSON data and may be accessed using the `cf` property: + +```python +>>> site = Site.objects.first() +>>> site.cf +{'site_code': 'US-RAL01'} +>>> site.cf['foo'] = 'ABC' +>>> site.full_clean() +>>> site.save() +>>> site = Site.objects.first() +>>> site.cf +{'foo': 'ABC', 'site_code': 'US-RAL01'} +``` + +Additionally, custom selection field choices are now defined on the CustomField model within the admin UI, which greatly simplifies working with choice values. + +#### Improved Cable Trace Performance ([#4900](https://github.com/netbox-community/netbox/issues/4900)) + +All end-to-end cable paths are now cached using the new CablePath backend model. This allows NetBox to now immediately return the complete path originating from any endpoint directly from the database, rather than having to trace each cable recursively. It also resolves some systemic validation issues present in the original implementation. + +**Note:** As part of this change, cable traces will no longer traverse circuits: A circuit termination will be considered the origin or destination of an end-to-end path. + +### Enhancements + +* [#609](https://github.com/netbox-community/netbox/issues/609) - Add min/max value and regex validation for custom fields +* [#1503](https://github.com/netbox-community/netbox/issues/1503) - Allow assigment of secrets to virtual machines +* [#1692](https://github.com/netbox-community/netbox/issues/1692) - Allow assigment of inventory items to parent items in web UI +* [#2179](https://github.com/netbox-community/netbox/issues/2179) - Support the use of multiple port numbers when defining a service +* [#4897](https://github.com/netbox-community/netbox/issues/4897) - Allow filtering by content type identified as `.` string +* [#4918](https://github.com/netbox-community/netbox/issues/4918) - Add a REST API endpoint (`/api/status/`) which returns NetBox's current operational status +* [#4956](https://github.com/netbox-community/netbox/issues/4956) - Include inventory items on primary device view +* [#4967](https://github.com/netbox-community/netbox/issues/4967) - Support tenant assignment for aggregates +* [#5003](https://github.com/netbox-community/netbox/issues/5003) - CSV import now accepts slug values for choice fields +* [#5146](https://github.com/netbox-community/netbox/issues/5146) - Add custom field support for cables, power panels, rack reservations, and virtual chassis +* [#5154](https://github.com/netbox-community/netbox/issues/5154) - The web interface now consumes the entire browser window +* [#5190](https://github.com/netbox-community/netbox/issues/5190) - Add a REST API endpoint for retrieving content types (`/api/extras/content-types/`) +* [#5274](https://github.com/netbox-community/netbox/issues/5274) - Add REST API support for custom fields +* [#5399](https://github.com/netbox-community/netbox/issues/5399) - Show options for cable endpoint types during bulk import +* [#5411](https://github.com/netbox-community/netbox/issues/5411) - Include cable tags in trace view + +### Other Changes + +* [#1846](https://github.com/netbox-community/netbox/issues/1846) - Enable MPTT for InventoryItem hierarchy +* [#2755](https://github.com/netbox-community/netbox/issues/2755) - Switched from Font Awesome/Glyphicons to Material Design icons +* [#4349](https://github.com/netbox-community/netbox/issues/4349) - Dropped support for embedded graphs +* [#4360](https://github.com/netbox-community/netbox/issues/4360) - Dropped support for the Django template language from export templates +* [#4711](https://github.com/netbox-community/netbox/issues/4711) - Renamed Webhook `obj_type` to `content_types` +* [#4941](https://github.com/netbox-community/netbox/issues/4941) - `commit` argument is now required argument in a custom script's `run()` method +* [#5011](https://github.com/netbox-community/netbox/issues/5011) - Standardized name field lengths across all models +* [#5139](https://github.com/netbox-community/netbox/issues/5139) - Omit utilization statistics from RIR list +* [#5225](https://github.com/netbox-community/netbox/issues/5225) - Circuit termination port speed is now an optional field + +### REST API Changes + +* Added support for `PUT`, `PATCH`, and `DELETE` operations on list endpoints (bulk update and delete) +* Added the `/extras/content-types/` endpoint for Django ContentTypes +* Added the `/extras/custom-fields/` endpoint for custom fields +* Removed the `/extras/_custom_field_choices/` endpoint (replaced by new custom fields endpoint) +* Added the `/status/` endpoint to convey NetBox's current status +* circuits.CircuitTermination: + * Added the `/trace/` endpoint + * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) + * Added `cable_peer` and `cable_peer_type` + * `port_speed` may now be null +* dcim.Cable: Added `custom_fields` +* dcim.ConsolePort: + * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) + * Added `cable_peer` and `cable_peer_type` + * Removed `connection_status` from nested serializer +* dcim.ConsoleServerPort: + * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) + * Added `cable_peer` and `cable_peer_type` + * Removed `connection_status` from nested serializer +* dcim.FrontPort: + * Replaced the `/trace/` endpoint with `/paths/`, which returns a list of cable paths + * Added `cable_peer` and `cable_peer_type` +* dcim.Interface: + * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) + * Added `cable_peer` and `cable_peer_type` + * Removed `connection_status` from nested serializer +* dcim.InventoryItem: The `_depth` field has been added to reflect MPTT positioning +* dcim.PowerFeed: + * Added the `/trace/` endpoint + * Added fields `connected_endpoint`, `connected_endpoint_type`, `connected_endpoint_reachable`, `cable_peer`, and `cable_peer_type` +* dcim.PowerOutlet: + * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) + * Added `cable_peer` and `cable_peer_type` + * Removed `connection_status` from nested serializer +* dcim.PowerPanel: Added `custom_fields` +* dcim.PowerPort + * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) + * Added `cable_peer` and `cable_peer_type` + * Removed `connection_status` from nested serializer +* dcim.RackReservation: Added `custom_fields` +* dcim.RearPort: + * Replaced the `/trace/` endpoint with `/paths/`, which returns a list of cable paths + * Added `cable_peer` and `cable_peer_type` +* dcim.VirtualChassis: Added `custom_fields` +* extras.ExportTemplate: The `template_language` field has been removed +* extras.Graph: This API endpoint has been removed (see #4349) +* extras.ImageAttachment: Filtering by `content_type` now takes a string in the form `.` +* extras.ObjectChange: Filtering by `changed_object_type` now takes a string in the form `.` +* ipam.Aggregate: Added `tenant` field +* ipam.RouteTarget: New endpoint +* ipam.Service: Renamed `port` to `ports`; now holds a list of one or more port numbers +* ipam.VRF: Added `import_targets` and `export_targets` fields +* secrets.Secret: Removed `device` field; replaced with `assigned_object` generic foreign key. This may represent either a device or a virtual machine. Assign an object by setting `assigned_object_type` and `assigned_object_id`. diff --git a/docs/release-notes/version-2.2.md b/docs/release-notes/version-2.2.md index 905b7a8d1..e13c4fe69 100644 --- a/docs/release-notes/version-2.2.md +++ b/docs/release-notes/version-2.2.md @@ -196,7 +196,7 @@ Our second-most popular feature request has arrived! NetBox now supports the cre #### Custom Validation Reports ([#1511](https://github.com/netbox-community/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. +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](https://netbox.readthedocs.io/en/stable/miscellaneous/reports/) for more info. ### Enhancements diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index 5ca86217a..af758f928 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -1,5 +1,95 @@ # NetBox v2.8 +## v2.8.9 (2020-08-04) + +### Enhancements + +* [#4898](https://github.com/netbox-community/netbox/issues/4898) - Add MAC address search field to interfaces list +* [#4899](https://github.com/netbox-community/netbox/issues/4899) - Add MAC address column to interfaces table + +### Bug Fixes + +* [#4455](https://github.com/netbox-community/netbox/issues/4455) - Fix ordering of prefixes beneath aggregate when available space is hidden +* [#4875](https://github.com/netbox-community/netbox/issues/4875) - Fix documentation for image attachments +* [#4876](https://github.com/netbox-community/netbox/issues/4876) - Fix labels for sites in staging or decommissioning status +* [#4880](https://github.com/netbox-community/netbox/issues/4880) - Fix removal of tagged VLANs if not assigned in bulk interface editing +* [#4887](https://github.com/netbox-community/netbox/issues/4887) - Don't disable NAPALM tabs when device has no primary IP +* [#4894](https://github.com/netbox-community/netbox/issues/4894) - Fix display of device/VM counts on platforms list +* [#4895](https://github.com/netbox-community/netbox/issues/4895) - Force UTF-8 encoding when embedding model documentation +* [#4910](https://github.com/netbox-community/netbox/issues/4910) - Unpin redis dependency to fix exception in RQ worker +* [#4926](https://github.com/netbox-community/netbox/issues/4926) - Fix ordering of VM interfaces in REST API endpoint +* [#4927](https://github.com/netbox-community/netbox/issues/4927) - Fix validation error when updating an existing secret +* [#4929](https://github.com/netbox-community/netbox/issues/4929) - Correct log message when creating a new object + +--- + +## v2.8.8 (2020-07-21) + +### Enhancements + +* [#4805](https://github.com/netbox-community/netbox/issues/4805) - Improve handling of plugin loading errors +* [#4829](https://github.com/netbox-community/netbox/issues/4829) - Add NEMA 15 power port and outlet types +* [#4831](https://github.com/netbox-community/netbox/issues/4831) - Allow NAPALM to resolve device name when primary IP is not set +* [#4854](https://github.com/netbox-community/netbox/issues/4854) - Add staging and decommissioning statuses for sites + +### Bug Fixes + +* [#3240](https://github.com/netbox-community/netbox/issues/3240) - Correct OpenAPI definition for available-prefixes endpoint +* [#4595](https://github.com/netbox-community/netbox/issues/4595) - Ensure consistent display of non-racked and child devices on rack view +* [#4803](https://github.com/netbox-community/netbox/issues/4803) - Return IP family (4 or 6) as integer rather than string +* [#4821](https://github.com/netbox-community/netbox/issues/4821) - Restrict group options by selected site when bulk editing VLANs +* [#4835](https://github.com/netbox-community/netbox/issues/4835) - Support passing multiple initial values for multiple choice fields +* [#4838](https://github.com/netbox-community/netbox/issues/4838) - Fix rack power utilization display for racks without devices +* [#4851](https://github.com/netbox-community/netbox/issues/4851) - Show locally connected peer on circuit terminations +* [#4856](https://github.com/netbox-community/netbox/issues/4856) - Redirect user back to circuit after connecting a termination +* [#4872](https://github.com/netbox-community/netbox/issues/4872) - Enable filtering virtual machine interfaces by tag + +--- + +## v2.8.7 (2020-07-02) + +### Enhancements + +* [#4796](https://github.com/netbox-community/netbox/issues/4796) - Introduce configuration parameters for default rack elevation size +* [#4802](https://github.com/netbox-community/netbox/issues/4802) - Allow changing page size when displaying only a single page of results + +### Bug Fixes + +* [#4695](https://github.com/netbox-community/netbox/issues/4695) - Expose cable termination type choices in OpenAPI spec +* [#4708](https://github.com/netbox-community/netbox/issues/4708) - Relax connection constraints for multi-position rear ports +* [#4766](https://github.com/netbox-community/netbox/issues/4766) - Fix redirect after login when `next` is not specified +* [#4771](https://github.com/netbox-community/netbox/issues/4771) - Fix add/remove tag population when bulk editing objects +* [#4772](https://github.com/netbox-community/netbox/issues/4772) - Fix "brief" format for the secrets REST API endpoint +* [#4774](https://github.com/netbox-community/netbox/issues/4774) - Fix exception when deleting a device with device bays +* [#4775](https://github.com/netbox-community/netbox/issues/4775) - Allow selecting an alternate device type when creating component templates + +--- + +## v2.8.6 (2020-06-15) + +### Enhancements + +* [#4698](https://github.com/netbox-community/netbox/issues/4698) - Improve display of template code for object in admin UI +* [#4717](https://github.com/netbox-community/netbox/issues/4717) - Introduce `ALLOWED_URL_SCHEMES` configuration parameter to mitigate dangerous hyperlinks +* [#4744](https://github.com/netbox-community/netbox/issues/4744) - Hide "IP addresses" tab when viewing a container prefix +* [#4755](https://github.com/netbox-community/netbox/issues/4755) - Enable creation of rack reservations directly from navigation menu +* [#4761](https://github.com/netbox-community/netbox/issues/4761) - Enable tag assignment during bulk creation of IP addresses + +### Bug Fixes + +* [#4674](https://github.com/netbox-community/netbox/issues/4674) - Fix API definition for available prefix and IP address endpoints +* [#4702](https://github.com/netbox-community/netbox/issues/4702) - Catch IntegrityError exception when adding a non-unique secret +* [#4707](https://github.com/netbox-community/netbox/issues/4707) - Fix `prefix_count` population on VLAN API serializer +* [#4710](https://github.com/netbox-community/netbox/issues/4710) - Fix merging of form fields among custom scripts +* [#4725](https://github.com/netbox-community/netbox/issues/4725) - Fix "brief" rendering of various REST API endpoints +* [#4736](https://github.com/netbox-community/netbox/issues/4736) - Add cable trace endpoints for pass-through ports +* [#4737](https://github.com/netbox-community/netbox/issues/4737) - Fix display of role labels in virtual machines table +* [#4743](https://github.com/netbox-community/netbox/issues/4743) - Allow users to create "next available" IPs without needing permission to create prefixes +* [#4756](https://github.com/netbox-community/netbox/issues/4756) - Filter parent group by site when creating rack groups +* [#4760](https://github.com/netbox-community/netbox/issues/4760) - Enable power port template assignment when bulk editing power outlet templates + +--- + ## v2.8.5 (2020-05-26) **Note:** The minimum required version of PostgreSQL is now 9.6. diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md new file mode 100644 index 000000000..4b359975c --- /dev/null +++ b/docs/release-notes/version-2.9.md @@ -0,0 +1,335 @@ +# NetBox v2.9 + +## v2.9.11 (2020-12-11) + +### Enhancements + +* [#5424](https://github.com/netbox-community/netbox/issues/5424) - Allow passing Python code to `nbshell` using `--command` +* [#5439](https://github.com/netbox-community/netbox/issues/5439) - Add CS and SN fiber port types + +### Bug Fixes + +* [#5383](https://github.com/netbox-community/netbox/issues/5383) - Fix setting user password via REST API +* [#5396](https://github.com/netbox-community/netbox/issues/5396) - Fix uniqueness constraint for virtual machine names +* [#5387](https://github.com/netbox-community/netbox/issues/5387) - Fix error when rendering config contexts when objects have multiple tags assigned +* [#5407](https://github.com/netbox-community/netbox/issues/5407) - Add direct link to secret on secrets list +* [#5408](https://github.com/netbox-community/netbox/issues/5408) - Fix updating secrets without setting new plaintext +* [#5410](https://github.com/netbox-community/netbox/issues/5410) - Restore tags field on cable connection forms +* [#5433](https://github.com/netbox-community/netbox/issues/5433) - Exclude SVG files from front/rear image upload for device types (currently unsupported) +* [#5436](https://github.com/netbox-community/netbox/issues/5436) - Show assigned IP addresses in interfaces list +* [#5446](https://github.com/netbox-community/netbox/issues/5446) - Fix validation for plugin version and required settings + +--- + +## v2.9.10 (2020-11-24) + +### Enhancements + +* [#5319](https://github.com/netbox-community/netbox/issues/5319) - Add USB types for power ports and outlets +* [#5337](https://github.com/netbox-community/netbox/issues/5337) - Add "splice" type for pass-through ports + +### Bug Fixes + +* [#5235](https://github.com/netbox-community/netbox/issues/5235) - Fix exception when editing IP address with a NAT IP assigned to a non-racked device +* [#5309](https://github.com/netbox-community/netbox/issues/5309) - Avoid extraneous database queries when manipulating objects +* [#5345](https://github.com/netbox-community/netbox/issues/5345) - Fix non-deterministic ordering of prefixes and IP addresses +* [#5350](https://github.com/netbox-community/netbox/issues/5350) - Filter available racks by selected group when creating a rack reservation +* [#5355](https://github.com/netbox-community/netbox/issues/5355) - Limit rack groups by selected site when editing a rack +* [#5356](https://github.com/netbox-community/netbox/issues/5356) - Populate manufacturer field when adding a device component template +* [#5360](https://github.com/netbox-community/netbox/issues/5360) - Clear VLAN assignments when setting interface mode to none + +--- + +## v2.9.9 (2020-11-09) + +### Enhancements + +* [#5304](https://github.com/netbox-community/netbox/issues/5304) - Return server error messages as JSON when handling REST API requests +* [#5310](https://github.com/netbox-community/netbox/issues/5310) - Link to rack groups within rack list table +* [#5327](https://github.com/netbox-community/netbox/issues/5327) - Be more strict when capturing anticipated ImportError exceptions + +### Bug Fixes + +* [#5271](https://github.com/netbox-community/netbox/issues/5271) - Fix auto-population of region field when editing a device +* [#5314](https://github.com/netbox-community/netbox/issues/5314) - Fix config context rendering when multiple tags are assigned to an object +* [#5316](https://github.com/netbox-community/netbox/issues/5316) - Dry running scripts should not trigger webhooks +* [#5324](https://github.com/netbox-community/netbox/issues/5324) - Add missing template extension tags for plugins for VM interface view +* [#5328](https://github.com/netbox-community/netbox/issues/5328) - Fix CreatedUpdatedFilterTest when running in non-UTC timezone +* [#5331](https://github.com/netbox-community/netbox/issues/5331) - Fix filtering of sites by null region + +--- + +## v2.9.8 (2020-10-30) + +### Enhancements + +* [#4559](https://github.com/netbox-community/netbox/issues/4559) - Improve device/VM context data rendering performance + +### Bug Fixes + +* [#3672](https://github.com/netbox-community/netbox/issues/3672) - Fix a caching issue causing incorrect related object counts in API responses +* [#5113](https://github.com/netbox-community/netbox/issues/5113) - Fix incorrect caching of permission object assignments to user groups in the admin panel +* [#5243](https://github.com/netbox-community/netbox/issues/5243) - Redirect user to appropriate tab after modifying device components +* [#5273](https://github.com/netbox-community/netbox/issues/5273) - Fix exception when validating a new permission with no models selected +* [#5282](https://github.com/netbox-community/netbox/issues/5282) - Fix high CPU load when LDAP authentication is enabled +* [#5285](https://github.com/netbox-community/netbox/issues/5285) - Plugins no longer need to define `app_name` for API URLs to be included in the root view + +--- + +## v2.9.7 (2020-10-12) + +### Bug Fixes + +* [#5231](https://github.com/netbox-community/netbox/issues/5231) - Fix KeyError exception when viewing object with custom link and debugging is disabled + +--- + +## v2.9.6 (2020-10-09) + +### Bug Fixes + +* [#5229](https://github.com/netbox-community/netbox/issues/5229) - Fix AttributeError exception when LDAP authentication is enabled + +--- + +## v2.9.5 (2020-10-09) + +### Enhancements + +* [#5202](https://github.com/netbox-community/netbox/issues/5202) - Extend the available context data when rendering custom links + +### Bug Fixes + +* [#4523](https://github.com/netbox-community/netbox/issues/4523) - Populate site vlan list when bulk editing interfaces under certain circumstances +* [#5174](https://github.com/netbox-community/netbox/issues/5174) - Ensure consistent alignment of rack elevations +* [#5175](https://github.com/netbox-community/netbox/issues/5175) - Fix toggling of rack elevation order +* [#5184](https://github.com/netbox-community/netbox/issues/5184) - Fix missing Power Utilization +* [#5197](https://github.com/netbox-community/netbox/issues/5197) - Limit duplicate IPs shown on IP address view +* [#5199](https://github.com/netbox-community/netbox/issues/5199) - Change default LDAP logging to INFO +* [#5201](https://github.com/netbox-community/netbox/issues/5201) - Fix missing querystring when bulk editing/deleting VLAN Group VLANs when selecting "select all x items matching query" +* [#5206](https://github.com/netbox-community/netbox/issues/5206) - Apply user pagination preferences to all paginated object lists +* [#5211](https://github.com/netbox-community/netbox/issues/5211) - Add missing `has_primary_ip` filter for virtual machines +* [#5217](https://github.com/netbox-community/netbox/issues/5217) - Prevent erroneous removal of prefetched GenericForeignKey data from tables +* [#5218](https://github.com/netbox-community/netbox/issues/5218) - Raise validation error if a power port's `allocated_draw` exceeds its `maximum_draw` +* [#5220](https://github.com/netbox-community/netbox/issues/5220) - Fix API patch request against IP Address endpoint with null assigned_object_type +* [#5221](https://github.com/netbox-community/netbox/issues/5221) - Fix bulk component creation for virtual machines +* [#5224](https://github.com/netbox-community/netbox/issues/5224) - Don't allow a rear port to have fewer positions than the number of mapped front ports +* [#5226](https://github.com/netbox-community/netbox/issues/5226) - Custom choice fields should be blank initially if no default choice has been designated + +--- + +## v2.9.4 (2020-09-23) + +**NOTE:** This release removes support for the `DEFAULT_TIMEOUT` parameter under `REDIS` database configuration. Set `RQ_DEFAULT_TIMEOUT` as a global configuration parameter instead. + +**NOTE:** Any permissions referencing the legacy ReportResult model (e.g. `extras.view_reportresult`) should be updated to reference the Report model. + +### Enhancements + +* [#1755](https://github.com/netbox-community/netbox/issues/1755) - Toggle order in which rack elevations are displayed +* [#5128](https://github.com/netbox-community/netbox/issues/5128) - Increase maximum rear port positions from 64 to 1024 +* [#5134](https://github.com/netbox-community/netbox/issues/5134) - Display full hierarchy in breadcrumbs for sites/racks +* [#5149](https://github.com/netbox-community/netbox/issues/5149) - Add rack group field to device edit form +* [#5164](https://github.com/netbox-community/netbox/issues/5164) - Show total rack count per rack group under site view +* [#5171](https://github.com/netbox-community/netbox/issues/5171) - Introduce the `RQ_DEFAULT_TIMEOUT` configuration parameter + +### Bug Fixes + +* [#5050](https://github.com/netbox-community/netbox/issues/5050) - Fix potential failure on `0016_replicate_interfaces` schema migration from old release +* [#5066](https://github.com/netbox-community/netbox/issues/5066) - Update `view_reportresult` to `view_report` permission +* [#5075](https://github.com/netbox-community/netbox/issues/5075) - Include a VLAN membership view for VM interfaces +* [#5105](https://github.com/netbox-community/netbox/issues/5105) - Validation should fail when reassigning a primary IP from device to VM +* [#5109](https://github.com/netbox-community/netbox/issues/5109) - Fix representation of custom choice field values for webhook data +* [#5108](https://github.com/netbox-community/netbox/issues/5108) - Fix execution of reports via CLI +* [#5111](https://github.com/netbox-community/netbox/issues/5111) - Allow use of tuples when specifying ObjectVar `query_params` +* [#5118](https://github.com/netbox-community/netbox/issues/5118) - Specifying an empty list of tags should clear assigned tags (REST API) +* [#5133](https://github.com/netbox-community/netbox/issues/5133) - Fix disassociation of an IP address from a VM interface +* [#5136](https://github.com/netbox-community/netbox/issues/5136) - Fix exception when bulk editing interface 802.1Q mode +* [#5156](https://github.com/netbox-community/netbox/issues/5156) - Add missing "add" button to rack reservations list +* [#5167](https://github.com/netbox-community/netbox/issues/5167) - Support filtering ObjectChanges by multiple users + +--- + +## v2.9.3 (2020-09-04) + +### Enhancements + +* [#4977](https://github.com/netbox-community/netbox/issues/4977) - Redirect authenticated users from login view +* [#5048](https://github.com/netbox-community/netbox/issues/5048) - Show the device/VM name when editing a component +* [#5072](https://github.com/netbox-community/netbox/issues/5072) - Add REST API filters for image attachments +* [#5080](https://github.com/netbox-community/netbox/issues/5080) - Add 8P6C, 8P4C, 8P2C port types + +### Bug Fixes + +* [#5046](https://github.com/netbox-community/netbox/issues/5046) - Disabled plugin menu items are no longer clickable +* [#5063](https://github.com/netbox-community/netbox/issues/5063) - Fix "add device" link in rack elevations for opposite side of half-depth devices +* [#5074](https://github.com/netbox-community/netbox/issues/5074) - Fix inclusion of VC member interfaces when viewing VC master +* [#5078](https://github.com/netbox-community/netbox/issues/5078) - Fix assignment of existing IP addresses to interfaces via web UI +* [#5081](https://github.com/netbox-community/netbox/issues/5081) - Fix exception during webhook processing with custom select field +* [#5085](https://github.com/netbox-community/netbox/issues/5085) - Fix ordering by assignment in IP addresses table +* [#5087](https://github.com/netbox-community/netbox/issues/5087) - Restore label field when editing console server ports, power ports, and power outlets +* [#5089](https://github.com/netbox-community/netbox/issues/5089) - Redirect to device view after editing component +* [#5090](https://github.com/netbox-community/netbox/issues/5090) - Fix status display for console/power/interface connections +* [#5091](https://github.com/netbox-community/netbox/issues/5091) - Avoid KeyError when handling invalid table preferences +* [#5095](https://github.com/netbox-community/netbox/issues/5095) - Show assigned prefixes in VLANs list + +--- + +## v2.9.2 (2020-08-27) + +### Enhancements + +* [#5055](https://github.com/netbox-community/netbox/issues/5055) - Add tags column to device/VM component list tables +* [#5056](https://github.com/netbox-community/netbox/issues/5056) - Add interface and parent columns to IP address list + +### Bug Fixes + +* [#4988](https://github.com/netbox-community/netbox/issues/4988) - Fix ordering of rack reservations with identical creation times +* [#5002](https://github.com/netbox-community/netbox/issues/5002) - Correct OpenAPI definition for `available-prefixes` endpoint +* [#5035](https://github.com/netbox-community/netbox/issues/5035) - Fix exception when modifying an IP address assigned to a VM +* [#5038](https://github.com/netbox-community/netbox/issues/5038) - Fix validation of primary IPs assigned to virtual machines +* [#5040](https://github.com/netbox-community/netbox/issues/5040) - Limit SLAAC status to IPv6 addresses +* [#5041](https://github.com/netbox-community/netbox/issues/5041) - Fix form tabs when assigning an IP to a VM interface +* [#5042](https://github.com/netbox-community/netbox/issues/5042) - Fix display of SLAAC label for IP addresses status +* [#5045](https://github.com/netbox-community/netbox/issues/5045) - Allow assignment of interfaces to non-master VC peer LAG during import +* [#5058](https://github.com/netbox-community/netbox/issues/5058) - Correct URL for front rack elevation images when using external storage +* [#5059](https://github.com/netbox-community/netbox/issues/5059) - Fix inclusion of checkboxes for interfaces in virtual machine view +* [#5060](https://github.com/netbox-community/netbox/issues/5060) - Fix validation when bulk-importing child devices +* [#5061](https://github.com/netbox-community/netbox/issues/5061) - Allow adding/removing tags when bulk editing virtual machine interfaces + +--- + +## v2.9.1 (2020-08-22) + +### Enhancements + +* [#4540](https://github.com/netbox-community/netbox/issues/4540) - Add IP address status type for SLAAC +* [#4814](https://github.com/netbox-community/netbox/issues/4814) - Allow nested LAG interfaces +* [#4991](https://github.com/netbox-community/netbox/issues/4991) - Add Python and NetBox versions to error page +* [#5033](https://github.com/netbox-community/netbox/issues/5033) - Support backward compatibility for `REMOTE_AUTH_BACKEND` configuration parameter + +--- + +## v2.9.0 (2020-08-21) + +**Note:** Redis 4.0 or later is required for this release. + +### New Features + +#### Object-Based Permissions ([#554](https://github.com/netbox-community/netbox/issues/554)) + +NetBox v2.9 replaces Django's built-in permissions framework with one that supports object-based assignment of permissions using arbitrary constraints. When granting a user or group permission to perform a certain action on one or more types of objects, an administrator can optionally specify a set of constraints. The permission will apply only to objects which match the specified constraints. For example, assigning permission to modify devices with the constraint `{"tenant__group__name": "Customers"}` would allow the associated users/groups to perform an action only on devices assigned to a tenant belonging to the "Customers" group. + +#### Background Execution of Scripts & Reports ([#2006](https://github.com/netbox-community/netbox/issues/2006)) + +When running a report or custom script, its execution is now queued for background processing and the user receives an immediate response indicating its status. This prevents long-running scripts from resulting in a timeout error. Once the execution has completed, the page will automatically refresh to display its results. Both scripts and reports now store their output in the new JobResult model. (The ReportResult model has been removed.) + +#### Named Virtual Chassis ([#2018](https://github.com/netbox-community/netbox/issues/2018)) + +The VirtualChassis model now has a mandatory `name` field. Names are assigned to the virtual chassis itself rather than referencing the master VC member. Additionally, the designation of a master is now optional: a virtual chassis may have only non-master members. + +#### Changes to Tag Creation ([#3703](https://github.com/netbox-community/netbox/issues/3703)) + +Tags are no longer created automatically: A tag must be created by a user before it can be applied to any object. Additionally, the REST API representation of assigned tags has been expanded to be consistent with other objects. + +#### Dedicated Model for VM Interfaces ([#4721](https://github.com/netbox-community/netbox/issues/4721)) + +A new model has been introduced to represent virtual machine interfaces. Although this change is largely transparent to the end user, note that the IP address model no longer has a foreign key to the Interface model under the DCIM app. This has been replaced with a generic foreign key named `assigned_object`. + +#### REST API Endpoints for Users and Groups ([#4877](https://github.com/netbox-community/netbox/issues/4877)) + +Two new REST API endpoints have been added to facilitate the retrieval and manipulation of users and groups: + +* `/api/users/groups/` +* `/api/users/users/` + +### Enhancements + +* [#4615](https://github.com/netbox-community/netbox/issues/4615) - Add `label` field for all device components and component templates +* [#4639](https://github.com/netbox-community/netbox/issues/4639) - Improve performance of web UI prefixes list +* [#4742](https://github.com/netbox-community/netbox/issues/4742) - Add tagging for cables, power panels, and rack reservations +* [#4788](https://github.com/netbox-community/netbox/issues/4788) - Add dedicated views for all device components +* [#4792](https://github.com/netbox-community/netbox/issues/4792) - Add bulk rename capability for console and power ports +* [#4793](https://github.com/netbox-community/netbox/issues/4793) - Add `description` field to device component templates +* [#4795](https://github.com/netbox-community/netbox/issues/4795) - Add bulk disconnect capability for console and power ports +* [#4806](https://github.com/netbox-community/netbox/issues/4806) - Add a `url` field to all API serializers +* [#4807](https://github.com/netbox-community/netbox/issues/4807) - Add bulk edit ability for device bay templates +* [#4817](https://github.com/netbox-community/netbox/issues/4817) - Standardize device/VM component `name` field to 64 characters +* [#4837](https://github.com/netbox-community/netbox/issues/4837) - Use dynamic form widget for relationships to MPTT objects (e.g. regions) +* [#4840](https://github.com/netbox-community/netbox/issues/4840) - Enable change logging for config contexts +* [#4885](https://github.com/netbox-community/netbox/issues/4885) - Add MultiChoiceVar for custom scripts +* [#4940](https://github.com/netbox-community/netbox/issues/4940) - Add an `occupied` field to rack unit representations for rack elevation views +* [#4945](https://github.com/netbox-community/netbox/issues/4945) - Add a user-friendly 403 error page +* [#4969](https://github.com/netbox-community/netbox/issues/4969) - Replace secret role user/group assignment with object permissions +* [#4982](https://github.com/netbox-community/netbox/issues/4982) - Extended ObjectVar to allow filtering API query +* [#4994](https://github.com/netbox-community/netbox/issues/4994) - Add `cable` attribute to PowerFeed API serializer +* [#4997](https://github.com/netbox-community/netbox/issues/4997) - The browsable API now lists available endpoints alphabetically +* [#5024](https://github.com/netbox-community/netbox/issues/5024) - List available options for choice fields within CSV import forms + +### Configuration Changes + +* If using NetBox's built-in remote authentication backend, update `REMOTE_AUTH_BACKEND` to `'netbox.authentication.RemoteUserBackend'`, as the authentication class has moved. +* If using LDAP authentication, set `REMOTE_AUTH_BACKEND` to `'netbox.authentication.LDAPBackend'`. (LDAP configuration parameters in `ldap_config.py` remain unchanged.) +* `REMOTE_AUTH_DEFAULT_PERMISSIONS` now takes a dictionary rather than a list. This is a mapping of permission names to a dictionary of constraining attributes, or `None`. For example, `['dcim.add_site', 'dcim.change_site']` would become `{'dcim.add_site': None, 'dcim.change_site': None}`. +* Backward compatibility for the old `webhooks` Redis queue name has been dropped. Ensure that your `REDIS` configuration parameter specifies both the `tasks` and `caching` databases. + +### REST API Changes + +* Added new endpoints for users, groups, and permissions under `/api/users/`. +* A `url` field is now included on all object representations, identifying the unique REST API URL for each object. +* The `tags` field of an object now includes a more complete representation of each tag, rather than just its name. +* The assignment of tags to an object is now achieved in the same manner as specifying any other related device. The `tags` field accepts a list of JSON objects each matching a desired tag. (Alternatively, a list of numeric primary keys corresponding to tags may be passed instead.) For example: + +```json +"tags": [ + {"name": "First Tag"}, + {"name": "Second Tag"} +] +``` + +* Legacy numeric values for choice fields are no longer conveyed or accepted. +* circuits.CircuitTermination: Added `cable` field +* dcim.Cable: Added `tags` field +* dcim.ConsolePort: Added `label` field +* dcim.ConsolePortTemplate: Added `description` and `label` fields +* dcim.ConsoleServerPort: Added `label` field +* dcim.ConsoleServerPortTemplate: Added `description` and `label` fields +* dcim.DeviceBay: Added `label` field +* dcim.DeviceBayTemplate: Added `description` and `label` fields +* dcim.FrontPort: Added `label` field +* dcim.FrontPortTemplate: Added `description` and `label` fields +* dcim.Interface: Added `label` field +* dcim.InterfaceTemplate: Added `description` and `label` fields +* dcim.PowerFeed: Added `cable` field +* dcim.PowerPanel: Added `tags` field +* dcim.PowerPort: Added ``label` field +* dcim.PowerPortTemplate: Added `description` and `label` fields +* dcim.PowerOutlet: Added `label` field +* dcim.PowerOutletTemplate: Added `description` and `label` fields +* dcim.Rack: Added an `occupied` field to rack unit representations for rack elevation views +* dcim.RackGroup: Added a `_depth` attribute indicating an object's position in the tree. +* dcim.RackReservation: Added `tags` field +* dcim.RearPort: Added `label` field +* dcim.RearPortTemplate: Added `description` and `label` fields +* dcim.Region: Added a `_depth` attribute indicating an object's position in the tree. +* dcim.VirtualChassis: Added `name` field (required) +* extras.ConfigContext: Added `created` and `last_updated` fields +* extras.JobResult: Added the `/api/extras/job-results/` endpoint +* extras.Report: The `failed` field has been removed. The `completed` (boolean) and `status` (string) fields have been introduced to convey the status of a report's most recent execution. Additionally, the `result` field now conveys the nested representation of a JobResult. +* extras.Script: Added `module` and `result` fields. The `result` field now conveys the nested representation of a JobResult. +* extras.Tag: The count of `tagged_items` is no longer included when viewing the tags list when `brief` is passed. +* ipam.IPAddress: Removed `interface` field; replaced with `assigned_object` generic foreign key. This may represent either a device interface or a virtual machine interface. Assign an object by setting `assigned_object_type` and `assigned_object_id`. +* ipam.VRF: Added `display_name` +* tenancy.TenantGroup: Added a `_depth` attribute indicating an object's position in the tree. +* users.ObjectPermissions: Added the `/api/users/permissions/` endpoint +* virtualization.VMInterface: Removed `type` field (VM interfaces have no type) + +### Other Changes + +* A new model, `VMInterface` has been introduced to represent interfaces assigned to VirtualMachine instances. Previously, these interfaces utilized the DCIM model `Interface`. Instances will be replicated automatically upon upgrade, however any custom code which references or manipulates virtual machine interfaces will need to be updated accordingly. +* The `secrets.activate_userkey` permission no longer exists. Instead, `secrets.change_userkey` is checked to determine whether a user has the ability to activate a UserKey. +* The `users.delete_token` permission is no longer enforced. All users are permitted to delete their own API tokens. +* Dropped backward compatibility for the `webhooks` Redis queue configuration (use `tasks` instead). +* Dropped backward compatibility for the `/admin/webhook-backend-status` URL (moved to `/admin/background-tasks/`). +* Virtual chassis are now created by navigating to `/dcim/virtual-chassis/add/` rather than via the devices list. +* A name is required when creating a virtual chassis. diff --git a/docs/rest-api/authentication.md b/docs/rest-api/authentication.md new file mode 100644 index 000000000..5d5777483 --- /dev/null +++ b/docs/rest-api/authentication.md @@ -0,0 +1,30 @@ +# REST API Authentication + +The NetBox REST API primarily employs token-based authentication. For convenience, cookie-based authentication can also be used when navigating the browsable API. + +{!docs/models/users/token.md!} + +## Authenticating to the API + +An authentication token is attached to a request by setting the `Authorization` header to the string `Token` followed by a space and the user's token: + +``` +$ curl -H "Authorization: Token $TOKEN" \ +-H "Accept: application/json; indent=4" \ +http://netbox/api/dcim/sites/ +{ + "count": 10, + "next": null, + "previous": null, + "results": [...] +} +``` + +A token is not required for read-only operations which have been exempted from permissions enforcement (using the [`EXEMPT_VIEW_PERMISSIONS`](../../configuration/optional-settings/#exempt_view_permissions) configuration parameter). However, if a token _is_ required but not present in a request, the API will return a 403 (Forbidden) response: + +``` +$ curl http://netbox/api/dcim/sites/ +{ + "detail": "Authentication credentials were not provided." +} +``` diff --git a/docs/rest-api/filtering.md b/docs/rest-api/filtering.md new file mode 100644 index 000000000..b77513297 --- /dev/null +++ b/docs/rest-api/filtering.md @@ -0,0 +1,87 @@ +# REST API Filtering + +## Filtering Objects + +The objects returned by an API list endpoint can be filtered by attaching one or more query parameters to the request URL. For example, `GET /api/dcim/sites/?status=active` will return only sites with a status of "active." + +Multiple parameters can be joined to further narrow results. For example, `GET /api/dcim/sites/?status=active®ion=europe` will return only active sites within the Europe region. + +Generally, passing multiple values for a single parameter will result in a logical OR operation. For example, `GET /api/dcim/sites/?region=north-america®ion=south-america` will return sites in North America _or_ South America. However, a logical AND operation will be used in instances where a field may have multiple values, such as tags. For example, `GET /api/dcim/sites/?tag=foo&tag=bar` will return only sites which have both the "foo" _and_ "bar" tags applied. + +### Filtering by Choice Field + +Some models have fields which are limited to specific choices, such as the `status` field on the Prefix model. To find all available choices for this field, make an authenticated `OPTIONS` request to the model's list endpoint, and use `jq` to extract the relevant parameters: + +```no-highlight +$ curl -s -X OPTIONS \ +-H "Authorization: Token $TOKEN" \ +-H "Content-Type: application/json" \ +http://netbox/api/ipam/prefixes/ | jq ".actions.POST.status.choices" +[ + { + "value": "container", + "display_name": "Container" + }, + { + "value": "active", + "display_name": "Active" + }, + { + "value": "reserved", + "display_name": "Reserved" + }, + { + "value": "deprecated", + "display_name": "Deprecated" + } +] +``` + +!!! note + The above works only if the API token used to authenticate the request has permission to make a `POST` request to this endpoint. + +### Filtering by Custom Field + +To filter results by a custom field value, prepend `cf_` to the custom field name. For example, the following query will return only sites where a custom field named `foo` is equal to 123: + +```no-highlight +GET /api/dcim/sites/?cf_foo=123 +``` + +Custom fields can be mixed with built-in fields to further narrow results. When creating a custom string field, the type of filtering selected (loose versus exact) determines whether partial or full matching is used. + +## Lookup Expressions + +Certain model fields also support filtering using additional lookup expressions. This allows +for negation and other context-specific filtering. + +These lookup expressions can be applied by adding a suffix to the desired field's name, e.g. `mac_address__n`. In this case, the filter expression is for negation and it is separated by two underscores. Below are the lookup expressions that are supported across different field types. + +### Numeric Fields + +Numeric based fields (ASN, VLAN ID, etc) support these lookup expressions: + +- `n` - not equal to (negation) +- `lt` - less than +- `lte` - less than or equal +- `gt` - greater than +- `gte` - greater than or equal + +### String Fields + +String based (char) fields (Name, Address, etc) support these lookup expressions: + +- `n` - not equal to (negation) +- `ic` - case insensitive contains +- `nic` - negated case insensitive contains +- `isw` - case insensitive starts with +- `nisw` - negated case insensitive starts with +- `iew` - case insensitive ends with +- `niew` - negated case insensitive ends with +- `ie` - case insensitive exact match +- `nie` - negated case insensitive exact match + +### Foreign Keys & Other Fields + +Certain other fields, namely foreign key relationships support just the negation +expression: `n`. diff --git a/docs/rest-api/overview.md b/docs/rest-api/overview.md new file mode 100644 index 000000000..290343aa6 --- /dev/null +++ b/docs/rest-api/overview.md @@ -0,0 +1,563 @@ +# REST API Overview + +## What is a REST API? + +REST stands for [representational state transfer](https://en.wikipedia.org/wiki/Representational_state_transfer). It's a particular type of API which employs HTTP requests and [JavaScript Object Notation (JSON)](https://www.json.org/) to facilitate create, retrieve, update, and delete (CRUD) operations on objects within an application. Each type of operation is associated with a particular HTTP verb: + +* `GET`: Retrieve an object or list of objects +* `POST`: Create an object +* `PUT` / `PATCH`: Modify an existing object. `PUT` requires all mandatory fields to be specified, while `PATCH` only expects the field that is being modified to be specified. +* `DELETE`: Delete an existing object + +Additionally, the `OPTIONS` verb can be used to inspect a particular REST API endpoint and return all supported actions and their available parameters. + +One of the primary benefits of a REST API is its human-friendliness. Because it utilizes HTTP and JSON, it's very easy to interact with NetBox data on the command line using common tools. For example, we can request an IP address from NetBox and output the JSON using `curl` and `jq`. The following command makes an HTTP `GET` request for information about a particular IP address, identified by its primary key, and uses `jq` to present the raw JSON data returned in a more human-friendly format. (Piping the output through `jq` isn't strictly required but makes it much easier to read.) + +```no-highlight +curl -s http://netbox/api/ipam/ip-addresses/2954/ | jq '.' +``` + +```json +{ + "id": 2954, + "url": "http://netbox/api/ipam/ip-addresses/2954/", + "family": { + "value": 4, + "label": "IPv4" + }, + "address": "192.168.0.42/26", + "vrf": null, + "tenant": null, + "status": { + "value": "active", + "label": "Active" + }, + "role": null, + "assigned_object_type": "dcim.interface", + "assigned_object_id": 114771, + "assigned_object": { + "id": 114771, + "url": "http://netbox/api/dcim/interfaces/114771/", + "device": { + "id": 2230, + "url": "http://netbox/api/dcim/devices/2230/", + "name": "router1", + "display_name": "router1" + }, + "name": "et-0/1/2", + "cable": null, + "connection_status": null + }, + "nat_inside": null, + "nat_outside": null, + "dns_name": "", + "description": "Example IP address", + "tags": [], + "custom_fields": {}, + "created": "2020-08-04", + "last_updated": "2020-08-04T14:12:39.666885Z" +} +``` + +Each attribute of the IP address is expressed as an attribute of the JSON object. Fields may include their own nested objects, as in the case of the `assigned_object` field above. Every object includes a primary key named `id` which uniquely identifies it in the database. + +## Interactive Documentation + +Comprehensive, interactive documentation of all REST API endpoints is available on a running NetBox instance at `/api/docs/`. This interface provides a convenient sandbox for researching and experimenting with specific endpoints and request types. The API itself can also be explored using a web browser by navigating to its root at `/api/`. + +## Endpoint Hierarchy + +NetBox's entire REST API is housed under the API root at `https:///api/`. The URL structure is divided at the root level by application: circuits, DCIM, extras, IPAM, plugins, secrets, tenancy, users, and virtualization. Within each application exists a separate path for each model. For example, the provider and circuit objects are located under the "circuits" application: + +* `/api/circuits/providers/` +* `/api/circuits/circuits/` + +Likewise, the site, rack, and device objects are located under the "DCIM" application: + +* `/api/dcim/sites/` +* `/api/dcim/racks/` +* `/api/dcim/devices/` + +The full hierarchy of available endpoints can be viewed by navigating to the API root in a web browser. + +Each model generally has two views associated with it: a list view and a detail view. The list view is used to retrieve a list of multiple objects and to create new objects. The detail view is used to retrieve, update, or delete an single existing object. All objects are referenced by their numeric primary key (`id`). + +* `/api/dcim/devices/` - List existing devices or create a new device +* `/api/dcim/devices/123/` - Retrieve, update, or delete the device with ID 123 + +Lists of objects can be filtered using a set of query parameters. For example, to find all interfaces belonging to the device with ID 123: + +``` +GET /api/dcim/interfaces/?device_id=123 +``` + +See the [filtering documentation](filtering.md) for more details. + +## Serialization + +The REST API employs two types of serializers to represent model data: base serializers and nested serializers. The base serializer is used to present the complete view of a model. This includes all database table fields which comprise the model, and may include additional metadata. A base serializer includes relationships to parent objects, but **does not** include child objects. For example, the `VLANSerializer` includes a nested representation its parent VLANGroup (if any), but does not include any assigned Prefixes. + +```json +{ + "id": 1048, + "site": { + "id": 7, + "url": "http://netbox/api/dcim/sites/7/", + "name": "Corporate HQ", + "slug": "corporate-hq" + }, + "group": { + "id": 4, + "url": "http://netbox/api/ipam/vlan-groups/4/", + "name": "Production", + "slug": "production" + }, + "vid": 101, + "name": "Users-Floor1", + "tenant": null, + "status": { + "value": 1, + "label": "Active" + }, + "role": { + "id": 9, + "url": "http://netbox/api/ipam/roles/9/", + "name": "User Access", + "slug": "user-access" + }, + "description": "", + "display_name": "101 (Users-Floor1)", + "custom_fields": {} +} +``` + +### Related Objects + +Related objects (e.g. `ForeignKey` fields) are represented using nested serializers. A nested serializer provides a minimal representation of an object, including only its direct URL and enough information to display the object to a user. When performing write API actions (`POST`, `PUT`, and `PATCH`), related objects may be specified by either numeric ID (primary key), or by a set of attributes sufficiently unique to return the desired object. + +For example, when creating a new device, its rack can be specified by NetBox ID (PK): + +```json +{ + "name": "MyNewDevice", + "rack": 123, + ... +} +``` + +Or by a set of nested attributes which uniquely identify the rack: + +```json +{ + "name": "MyNewDevice", + "rack": { + "site": { + "name": "Equinix DC6" + }, + "name": "R204" + }, + ... +} +``` + +Note that if the provided parameters do not return exactly one object, a validation error is raised. + +### Generic Relations + +Some objects within NetBox have attributes which can reference an object of multiple types, known as _generic relations_. For example, an IP address can be assigned to either a device interface _or_ a virtual machine interface. When making this assignment via the REST API, we must specify two attributes: + +* `assigned_object_type` - The content type of the assigned object, defined as `.` +* `assigned_object_id` - The assigned object's unique numeric ID + +Together, these values identify a unique object in NetBox. The assigned object (if any) is represented by the `assigned_object` attribute on the IP address model. + +```no-highlight +curl -X POST \ +-H "Authorization: Token $TOKEN" \ +-H "Content-Type: application/json" \ +-H "Accept: application/json; indent=4" \ +http://netbox/api/ipam/ip-addresses/ \ +--data '{ + "address": "192.0.2.1/24", + "assigned_object_type": "dcim.interface", + "assigned_object_id": 69023 +}' +``` + +```json +{ + "id": 56296, + "url": "http://netbox/api/ipam/ip-addresses/56296/", + "assigned_object_type": "dcim.interface", + "assigned_object_id": 69000, + "assigned_object": { + "id": 69000, + "url": "http://netbox/api/dcim/interfaces/69023/", + "device": { + "id": 2174, + "url": "http://netbox/api/dcim/devices/2174/", + "name": "device105", + "display_name": "device105" + }, + "name": "ge-0/0/0", + "cable": null, + "connection_status": null + }, + ... +} +``` + +If we wanted to assign this IP address to a virtual machine interface instead, we would have set `assigned_object_type` to `virtualization.vminterface` and updated the object ID appropriately. + +### 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 available objects without any related data, such as when populating a drop-down list in a form. As an example, the default (complete) format of an IP address looks like this: + +``` +GET /api/ipam/prefixes/13980/ + +{ + "id": 13980, + "url": "http://netbox/api/ipam/prefixes/13980/", + "family": { + "value": 4, + "label": "IPv4" + }, + "prefix": "192.0.2.0/24", + "site": { + "id": 3, + "url": "http://netbox/api/dcim/sites/17/", + "name": "Site 23A", + "slug": "site-23a" + }, + "vrf": null, + "tenant": null, + "vlan": null, + "status": { + "value": "container", + "label": "Container" + }, + "role": { + "id": 17, + "url": "http://netbox/api/ipam/roles/17/", + "name": "Staging", + "slug": "staging" + }, + "is_pool": false, + "description": "Example prefix", + "tags": [], + "custom_fields": {}, + "created": "2018-12-10", + "last_updated": "2019-03-01T20:02:46.173540Z" +} +``` + +The brief format is much more terse: + +``` +GET /api/ipam/prefixes/13980/?brief=1 + +{ + "id": 13980, + "url": "http://netbox/api/ipam/prefixes/13980/", + "family": 4, + "prefix": "10.40.3.0/24" +} +``` + +The brief format is supported for both lists and individual objects. + +### Excluding Config Contexts + +When retrieving devices and virtual machines via the REST API, each will included its rendered [configuration context data](../models/extras/configcontext/) by default. Users with large amounts of context data will likely observe suboptimal performance when returning multiple objects, particularly with very high page sizes. To combat this, context data may be excluded from the response data by attaching the query parameter `?exclude=config_context` to the request. This parameter works for both list and detail views. + +## Pagination + +API responses which contain a list of many objects will be paginated for efficiency. The root JSON object returned by a list endpoint contains the following attributes: + +* `count`: The total number of all objects matching the query +* `next`: A hyperlink to the next page of results (if applicable) +* `previous`: A hyperlink to the previous page of results (if applicable) +* `results`: The list of objects on the current page + +Here is an example of a paginated response: + +``` +HTTP 200 OK +Allow: GET, POST, OPTIONS +Content-Type: application/json +Vary: Accept + +{ + "count": 2861, + "next": "http://netbox/api/dcim/devices/?limit=50&offset=50", + "previous": null, + "results": [ + { + "id": 231, + "name": "Device1", + ... + }, + { + "id": 232, + "name": "Device2", + ... + }, + ... + ] +} +``` + +The default page is determined by the [`PAGINATE_COUNT`](../../configuration/optional-settings/#paginate_count) configuration parameter, which defaults to 50. However, this can be overridden per request by specifying the desired `offset` and `limit` query parameters. For example, if you wish to retrieve a hundred devices at a time, you would make a request for: + +``` +http://netbox/api/dcim/devices/?limit=100 +``` + +The response will return devices 1 through 100. The URL provided in the `next` attribute of the response will return devices 101 through 200: + +```json +{ + "count": 2861, + "next": "http://netbox/api/dcim/devices/?limit=100&offset=100", + "previous": null, + "results": [...] +} +``` + +The maximum number of objects that can be returned is limited by the [`MAX_PAGE_SIZE`](../../configuration/optional-settings/#max_page_size) configuration parameter, which is 1000 by default. Setting this to `0` or `None` will remove the maximum limit. An API consumer can then pass `?limit=0` to retrieve _all_ matching objects with a single request. + +!!! warning + Disabling the page size limit introduces a potential for very resource-intensive requests, since one API request can effectively retrieve an entire table from the database. + +## Interacting with Objects + +### Retrieving Multiple Objects + +To query NetBox for a list of objects, make a `GET` request to the model's _list_ endpoint. Objects are listed under the response object's `results` parameter. + +```no-highlight +curl -s -X GET http://netbox/api/ipam/ip-addresses/ | jq '.' +``` + +```json +{ + "count": 42031, + "next": "http://netbox/api/ipam/ip-addresses/?limit=50&offset=50", + "previous": null, + "results": [ + { + "id": 5618, + "address": "192.0.2.1/24", + ... + }, + { + "id": 5619, + "address": "192.0.2.2/24", + ... + }, + { + "id": 5620, + "address": "192.0.2.3/24", + ... + }, + ... + ] +} +``` + +### Retrieving a Single Object + +To query NetBox for a single object, make a `GET` request to the model's _detail_ endpoint specifying its unique numeric ID. + +!!! note + Note that the trailing slash is required. Omitting this will return a 302 redirect. + +```no-highlight +curl -s -X GET http://netbox/api/ipam/ip-addresses/5618/ | jq '.' +``` + +```json +{ + "id": 5618, + "address": "192.0.2.1/24", + ... +} +``` + +### Creating a New Object + +To create a new object, make a `POST` request to the model's _list_ endpoint with JSON data pertaining to the object being created. Note that a REST API token is required for all write operations; see the [authentication documentation](../authentication/) for more information. Also be sure to set the `Content-Type` HTTP header to `application/json`. + +```no-highlight +curl -s -X POST \ +-H "Authorization: Token $TOKEN" \ +-H "Content-Type: application/json" \ +http://netbox/api/ipam/prefixes/ \ +--data '{"prefix": "192.0.2.0/24", "site": 6}' | jq '.' +``` + +```json +{ + "id": 18691, + "url": "http://netbox/api/ipam/prefixes/18691/", + "family": { + "value": 4, + "label": "IPv4" + }, + "prefix": "192.0.2.0/24", + "site": { + "id": 6, + "url": "http://netbox/api/dcim/sites/6/", + "name": "US-East 4", + "slug": "us-east-4" + }, + "vrf": null, + "tenant": null, + "vlan": null, + "status": { + "value": "active", + "label": "Active" + }, + "role": null, + "is_pool": false, + "description": "", + "tags": [], + "custom_fields": {}, + "created": "2020-08-04", + "last_updated": "2020-08-04T20:08:39.007125Z" +} +``` + +### Creating Multiple Objects + +To create multiple instances of a model using a single request, make a `POST` request to the model's _list_ endpoint with a list of JSON objects representing each instance to be created. If successful, the response will contain a list of the newly created instances. The example below illustrates the creation of three new sites. + +```no-highlight +curl -X POST -H "Authorization: Token $TOKEN" \ +-H "Content-Type: application/json" \ +-H "Accept: application/json; indent=4" \ +http://netbox/api/dcim/sites/ \ +--data '[ +{"name": "Site 1", "slug": "site-1", "region": {"name": "United States"}}, +{"name": "Site 2", "slug": "site-2", "region": {"name": "United States"}}, +{"name": "Site 3", "slug": "site-3", "region": {"name": "United States"}} +]' +``` + +```json +[ + { + "id": 21, + "url": "http://netbox/api/dcim/sites/21/", + "name": "Site 1", + ... + }, + { + "id": 22, + "url": "http://netbox/api/dcim/sites/22/", + "name": "Site 2", + ... + }, + { + "id": 23, + "url": "http://netbox/api/dcim/sites/23/", + "name": "Site 3", + ... + } +] +``` + +### Updating an Object + +To modify an object which has already been created, make a `PATCH` request to the model's _detail_ endpoint specifying its unique numeric ID. Include any data which you wish to update on the object. As with object creation, the `Authorization` and `Content-Type` headers must also be specified. + +```no-highlight +curl -s -X PATCH \ +-H "Authorization: Token $TOKEN" \ +-H "Content-Type: application/json" \ +http://netbox/api/ipam/prefixes/18691/ \ +--data '{"status": "reserved"}' | jq '.' +``` + +```json +{ + "id": 18691, + "url": "http://netbox/api/ipam/prefixes/18691/", + "family": { + "value": 4, + "label": "IPv4" + }, + "prefix": "192.0.2.0/24", + "site": { + "id": 6, + "url": "http://netbox/api/dcim/sites/6/", + "name": "US-East 4", + "slug": "us-east-4" + }, + "vrf": null, + "tenant": null, + "vlan": null, + "status": { + "value": "reserved", + "label": "Reserved" + }, + "role": null, + "is_pool": false, + "description": "", + "tags": [], + "custom_fields": {}, + "created": "2020-08-04", + "last_updated": "2020-08-04T20:14:55.709430Z" +} +``` + +!!! note "PUT versus PATCH" + The NetBox REST API support the use of either `PUT` or `PATCH` to modify an existing object. The difference is that a `PUT` request requires the user to specify a _complete_ representation of the object being modified, whereas a `PATCH` request need include only the attributes that are being updated. For most purposes, using `PATCH` is recommended. + +### Updating Multiple Objects + +Multiple objects can be updated simultaneously by issuing a `PUT` or `PATCH` request to a model's list endpoint with a list of dictionaries specifying the numeric ID of each object to be deleted and the attributes to be updated. For example, to update sites with IDs 10 and 11 to a status of "active", issue the following request: + +```no-highlight +curl -s -X PATCH \ +-H "Authorization: Token $TOKEN" \ +-H "Content-Type: application/json" \ +http://netbox/api/dcim/sites/ \ +--data '[{"id": 10, "status": "active"}, {"id": 11, "status": "active"}]' +``` + +Note that there is no requirement for the attributes to be identical among objects. For instance, it's possible to update the status of one site along with the name of another in the same request. + +!!! note + The bulk update of objects is an all-or-none operation, meaning that if NetBox fails to successfully update any of the specified objects (e.g. due a validation error), the entire operation will be aborted and none of the objects will be updated. + +### Deleting an Object + +To delete an object from NetBox, make a `DELETE` request to the model's _detail_ endpoint specifying its unique numeric ID. The `Authorization` header must be included to specify an authorization token, however this type of request does not support passing any data in the body. + +```no-highlight +curl -s -X DELETE \ +-H "Authorization: Token $TOKEN" \ +http://netbox/api/ipam/prefixes/18691/ +``` + +Note that `DELETE` requests do not return any data: If successful, the API will return a 204 (No Content) response. + +!!! note + You can run `curl` with the verbose (`-v`) flag to inspect the HTTP response codes. + +### Deleting Multiple Objects + +NetBox supports the simultaneous deletion of multiple objects of the same type by issuing a `DELETE` request to the model's list endpoint with a list of dictionaries specifying the numeric ID of each object to be deleted. For example, to delete sites with IDs 10, 11, and 12, issue the following request: + +```no-highlight +curl -s -X DELETE \ +-H "Authorization: Token $TOKEN" \ +-H "Content-Type: application/json" \ +http://netbox/api/dcim/sites/ \ +--data '[{"id": 10}, {"id": 11}, {"id": 12}]' +``` + +!!! note + The bulk deletion of objects is an all-or-none operation, meaning that if NetBox fails to delete any of the specified objects (e.g. due a dependency by a related object), the entire operation will be aborted and none of the objects will be deleted. diff --git a/docs/api/working-with-secrets.md b/docs/rest-api/working-with-secrets.md similarity index 54% rename from docs/api/working-with-secrets.md rename to docs/rest-api/working-with-secrets.md index 129bd0855..dafbb7239 100644 --- a/docs/api/working-with-secrets.md +++ b/docs/rest-api/working-with-secrets.md @@ -1,16 +1,19 @@ # Working with Secrets -As with most other objects, the NetBox API can be used to create, modify, and delete secrets. However, additional steps are needed to encrypt or decrypt secret data. +As with most other objects, the REST API can be used to view, create, modify, and delete secrets. However, additional steps are needed to encrypt or decrypt secret data. ## Generating a Session Key In order to encrypt or decrypt secret data, a session key must be attached to the API request. To generate a session key, send an authenticated request to the `/api/secrets/get-session-key/` endpoint with the private RSA key which matches your [UserKey](../../core-functionality/secrets/#user-keys). The private key must be POSTed with the name `private_key`. -``` -$ curl -X POST http://localhost:8000/api/secrets/get-session-key/ \ --H "Authorization: Token c639d619ecbeb1f3055c4141ba6870e20572edd7" \ +```no-highlight +$ curl -X POST http://netbox/api/secrets/get-session-key/ \ +-H "Authorization: Token $TOKEN" \ -H "Accept: application/json; indent=4" \ --data-urlencode "private_key@" +``` + +```json { "session_key": "dyEnxlc9lnGzaOAV1dV/xqYPV63njIbdZYOgnAlGPHk=" } @@ -19,94 +22,106 @@ $ curl -X POST http://localhost:8000/api/secrets/get-session-key/ \ !!! note To read the private key from a file, use the convention above. Alternatively, the private key can be read from an environment variable using `--data-urlencode "private_key=$PRIVATE_KEY"`. -The request uses your private key to unlock your stored copy of the master key and generate a session key which can be attached in the `X-Session-Key` header of future API requests. +The request uses the provided private key to unlock your stored copy of the master key and generate a temporary session key, which can be attached in the `X-Session-Key` header of future API requests. ## Retrieving Secrets A session key is not needed to retrieve unencrypted secrets: The secret is returned like any normal object with its `plaintext` field set to null. -``` -$ curl http://localhost:8000/api/secrets/secrets/2587/ \ --H "Authorization: Token c639d619ecbeb1f3055c4141ba6870e20572edd7" \ +```no-highlight +$ curl http://netbox/api/secrets/secrets/2587/ \ +-H "Authorization: Token $TOKEN" \ -H "Accept: application/json; indent=4" +``` + +```json { "id": 2587, + "url": "http://netbox/api/secrets/secrets/2587/", "device": { "id": 1827, - "url": "http://localhost:8000/api/dcim/devices/1827/", + "url": "http://netbox/api/dcim/devices/1827/", "name": "MyTestDevice", "display_name": "MyTestDevice" }, "role": { "id": 1, - "url": "http://localhost:8000/api/secrets/secret-roles/1/", + "url": "http://netbox/api/secrets/secret-roles/1/", "name": "Login Credentials", "slug": "login-creds" }, "name": "admin", "plaintext": null, "hash": "pbkdf2_sha256$1000$G6mMFe4FetZQ$f+0itZbAoUqW5pd8+NH8W5rdp/2QNLIBb+LGdt4OSKA=", + "tags": [], + "custom_fields": {}, "created": "2017-03-21", "last_updated": "2017-03-21T19:28:44.265582Z" } ``` -To decrypt a secret, we must include our session key in the `X-Session-Key` header: +To decrypt a secret, we must include our session key in the `X-Session-Key` header when sending the `GET` request: -``` -$ curl http://localhost:8000/api/secrets/secrets/2587/ \ --H "Authorization: Token c639d619ecbeb1f3055c4141ba6870e20572edd7" \ +```no-highlight +$ curl http://netbox/api/secrets/secrets/2587/ \ +-H "Authorization: Token $TOKEN" \ -H "Accept: application/json; indent=4" \ -H "X-Session-Key: dyEnxlc9lnGzaOAV1dV/xqYPV63njIbdZYOgnAlGPHk=" +``` + +```json { "id": 2587, + "url": "http://netbox/api/secrets/secrets/2587/", "device": { "id": 1827, - "url": "http://localhost:8000/api/dcim/devices/1827/", + "url": "http://netbox/api/dcim/devices/1827/", "name": "MyTestDevice", "display_name": "MyTestDevice" }, "role": { "id": 1, - "url": "http://localhost:8000/api/secrets/secret-roles/1/", + "url": "http://netbox/api/secrets/secret-roles/1/", "name": "Login Credentials", "slug": "login-creds" }, "name": "admin", "plaintext": "foobar", "hash": "pbkdf2_sha256$1000$G6mMFe4FetZQ$f+0itZbAoUqW5pd8+NH8W5rdp/2QNLIBb+LGdt4OSKA=", + "tags": [], + "custom_fields": {}, "created": "2017-03-21", "last_updated": "2017-03-21T19:28:44.265582Z" } ``` -Lists of secrets can be decrypted in this manner as well: +Multiple secrets within a list can be decrypted in this manner as well: -``` -$ curl http://localhost:8000/api/secrets/secrets/?limit=3 \ --H "Authorization: Token c639d619ecbeb1f3055c4141ba6870e20572edd7" \ +```no-highlight +$ curl http://netbox/api/secrets/secrets/?limit=3 \ +-H "Authorization: Token $TOKEN" \ -H "Accept: application/json; indent=4" \ -H "X-Session-Key: dyEnxlc9lnGzaOAV1dV/xqYPV63njIbdZYOgnAlGPHk=" +``` + +```json { "count": 3482, - "next": "http://localhost:8000/api/secrets/secrets/?limit=3&offset=3", + "next": "http://netbox/api/secrets/secrets/?limit=3&offset=3", "previous": null, "results": [ { "id": 2587, - ... "plaintext": "foobar", ... }, { "id": 2588, - ... "plaintext": "MyP@ssw0rd!", ... }, { "id": 2589, - ... "plaintext": "AnotherSecret!", ... }, @@ -114,25 +129,44 @@ $ curl http://localhost:8000/api/secrets/secrets/?limit=3 \ } ``` -## Creating Secrets +## Creating and Updating Secrets -Session keys are also used to decrypt new or modified secrets. This is done by setting the `plaintext` field of the submitted object: +Session keys are required when creating or modifying secrets. The secret's `plaintext` attribute is set to its non-encrypted value, and NetBox uses the session key to compute and store the encrypted value. -``` -$ curl -X POST http://localhost:8000/api/secrets/secrets/ \ +```no-highlight +$ curl -X POST http://netbox/api/secrets/secrets/ \ -H "Content-Type: application/json" \ --H "Authorization: Token c639d619ecbeb1f3055c4141ba6870e20572edd7" \ +-H "Authorization: Token $TOKEN" \ -H "Accept: application/json; indent=4" \ -H "X-Session-Key: dyEnxlc9lnGzaOAV1dV/xqYPV63njIbdZYOgnAlGPHk=" \ --data '{"device": 1827, "role": 1, "name": "backup", "plaintext": "Drowssap1"}' +``` + +```json { - "id": 2590, - "device": 1827, - "role": 1, + "id": 6194, + "url": "http://netbox/api/secrets/secrets/9194/", + "device": { + "id": 1827, + "url": "http://netbox/api/dcim/devices/1827/", + "name": "device43", + "display_name": "device43" + }, + "role": { + "id": 1, + "url": "http://netbox/api/secrets/secret-roles/1/", + "name": "Login Credentials", + "slug": "login-creds" + }, "name": "backup", - "plaintext": "Drowssap1" + "plaintext": "Drowssap1", + "hash": "pbkdf2_sha256$1000$J9db8sI5vBrd$IK6nFXnFl+K+nR5/KY8RSDxU1skYL8G69T5N3jZxM7c=", + "tags": [], + "custom_fields": {}, + "created": "2020-08-05", + "last_updated": "2020-08-05T16:51:14.990506Z" } ``` !!! note - Don't forget to include the `Content-Type: application/json` header when making a POST request. + Don't forget to include the `Content-Type: application/json` header when making a POST or PATCH request. diff --git a/mkdocs.yml b/mkdocs.yml index b8633ea8f..092cb559a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -20,8 +20,9 @@ nav: - 1. PostgreSQL: 'installation/1-postgresql.md' - 2. Redis: 'installation/2-redis.md' - 3. NetBox: 'installation/3-netbox.md' - - 4. HTTP Daemon: 'installation/4-http-daemon.md' - - 5. LDAP (Optional): 'installation/5-ldap.md' + - 4. Gunicorn: 'installation/4-gunicorn.md' + - 5. HTTP Server: 'installation/5-http-server.md' + - 6. LDAP (Optional): 'installation/6-ldap.md' - Upgrading NetBox: 'installation/upgrading.md' - Migrating to systemd: 'installation/migrating-to-systemd.md' - Configuration: @@ -39,42 +40,43 @@ nav: - Circuits: 'core-functionality/circuits.md' - Power Tracking: 'core-functionality/power.md' - Secrets: 'core-functionality/secrets.md' - - Tenancy Assignment: 'core-functionality/tenancy.md' + - Tenancy: 'core-functionality/tenancy.md' - Additional Features: - Caching: 'additional-features/caching.md' - Change Logging: 'additional-features/change-logging.md' - - Context Data: 'additional-features/context-data.md' + - Context Data: 'models/extras/configcontext.md' - Custom Fields: 'additional-features/custom-fields.md' - Custom Links: 'additional-features/custom-links.md' - Custom Scripts: 'additional-features/custom-scripts.md' - Export Templates: 'additional-features/export-templates.md' - - Graphs: 'additional-features/graphs.md' - NAPALM: 'additional-features/napalm.md' - Prometheus Metrics: 'additional-features/prometheus-metrics.md' - Reports: 'additional-features/reports.md' - - Tags: 'additional-features/tags.md' + - Tags: 'models/extras/tag.md' - Webhooks: 'additional-features/webhooks.md' - Plugins: - Using Plugins: 'plugins/index.md' - Developing Plugins: 'plugins/development.md' - Administration: + - Permissions: 'administration/permissions.md' - Replicating NetBox: 'administration/replicating-netbox.md' - NetBox Shell: 'administration/netbox-shell.md' - - API: - - Overview: 'api/overview.md' - - Filtering: 'api/filtering.md' - - Authentication: 'api/authentication.md' - - Working with Secrets: 'api/working-with-secrets.md' - - Examples: 'api/examples.md' + - REST API: + - Overview: 'rest-api/overview.md' + - Filtering: 'rest-api/filtering.md' + - Authentication: 'rest-api/authentication.md' + - Working with Secrets: 'rest-api/working-with-secrets.md' - Development: - Introduction: 'development/index.md' + - Getting Started: 'development/getting-started.md' - Style Guide: 'development/style-guide.md' - - Utility Views: 'development/utility-views.md' - Extending Models: 'development/extending-models.md' - Application Registry: 'development/application-registry.md' - User Preferences: 'development/user-preferences.md' - Release Checklist: 'development/release-checklist.md' - Release Notes: + - Version 2.10: 'release-notes/version-2.10.md' + - Version 2.9: 'release-notes/version-2.9.md' - Version 2.8: 'release-notes/version-2.8.md' - Version 2.7: 'release-notes/version-2.7.md' - Version 2.6: 'release-notes/version-2.6.md' diff --git a/netbox/circuits/api/nested_serializers.py b/netbox/circuits/api/nested_serializers.py index 067b82282..2d3457d2c 100644 --- a/netbox/circuits/api/nested_serializers.py +++ b/netbox/circuits/api/nested_serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from circuits.models import Circuit, CircuitTermination, CircuitType, Provider -from utilities.api import WritableNestedSerializer +from netbox.api import WritableNestedSerializer __all__ = [ 'NestedCircuitSerializer', @@ -51,4 +51,4 @@ class NestedCircuitTerminationSerializer(WritableNestedSerializer): class Meta: model = CircuitTermination - fields = ['id', 'url', 'circuit', 'term_side'] + fields = ['id', 'url', 'circuit', 'term_side', 'cable'] diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 6bac48a59..88890bf95 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -1,13 +1,13 @@ from rest_framework import serializers -from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField from circuits.choices import CircuitStatusChoices from circuits.models import Provider, Circuit, CircuitTermination, CircuitType from dcim.api.nested_serializers import NestedCableSerializer, NestedInterfaceSerializer, NestedSiteSerializer -from dcim.api.serializers import ConnectedEndpointSerializer +from dcim.api.serializers import CableTerminationSerializer, ConnectedEndpointSerializer from extras.api.customfields import CustomFieldModelSerializer +from extras.api.serializers import TaggedObjectSerializer +from netbox.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer from tenancy.api.nested_serializers import NestedTenantSerializer -from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer from .nested_serializers import * @@ -15,14 +15,14 @@ from .nested_serializers import * # Providers # -class ProviderSerializer(TaggitSerializer, CustomFieldModelSerializer): - tags = TagListSerializerField(required=False) +class ProviderSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail') circuit_count = serializers.IntegerField(read_only=True) class Meta: model = Provider fields = [ - 'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags', + 'id', 'url', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', ] @@ -32,11 +32,12 @@ class ProviderSerializer(TaggitSerializer, CustomFieldModelSerializer): # class CircuitTypeSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail') circuit_count = serializers.IntegerField(read_only=True) class Meta: model = CircuitType - fields = ['id', 'name', 'slug', 'description', 'circuit_count'] + fields = ['id', 'url', 'name', 'slug', 'description', 'circuit_count'] class CircuitCircuitTerminationSerializer(WritableNestedSerializer): @@ -49,24 +50,25 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer): fields = ['id', 'url', 'site', 'connected_endpoint', 'port_speed', 'upstream_speed', 'xconnect_id'] -class CircuitSerializer(TaggitSerializer, CustomFieldModelSerializer): +class CircuitSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail') provider = NestedProviderSerializer() status = ChoiceField(choices=CircuitStatusChoices, required=False) type = NestedCircuitTypeSerializer() tenant = NestedTenantSerializer(required=False, allow_null=True) termination_a = CircuitCircuitTerminationSerializer(read_only=True) termination_z = CircuitCircuitTerminationSerializer(read_only=True) - tags = TagListSerializerField(required=False) class Meta: model = Circuit fields = [ - 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', + 'id', 'url', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] -class CircuitTerminationSerializer(ConnectedEndpointSerializer): +class CircuitTerminationSerializer(CableTerminationSerializer, ConnectedEndpointSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') circuit = NestedCircuitSerializer() site = NestedSiteSerializer() cable = NestedCableSerializer(read_only=True) @@ -74,6 +76,7 @@ class CircuitTerminationSerializer(ConnectedEndpointSerializer): class Meta: model = CircuitTermination fields = [ - 'id', 'circuit', 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', - 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', + 'id', 'url', 'circuit', 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', + 'description', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'connected_endpoint_reachable' ] diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py index 01fbfb62c..b496796fe 100644 --- a/netbox/circuits/api/urls.py +++ b/netbox/circuits/api/urls.py @@ -1,18 +1,9 @@ -from rest_framework import routers - +from netbox.api import OrderedDefaultRouter from . import views -class CircuitsRootView(routers.APIRootView): - """ - Circuits API root view - """ - def get_view_name(self): - return 'Circuits' - - -router = routers.DefaultRouter() -router.APIRootView = CircuitsRootView +router = OrderedDefaultRouter() +router.APIRootView = views.CircuitsRootView # Providers router.register('providers', views.ProviderViewSet) diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 363392a4d..736871a73 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -1,38 +1,34 @@ -from django.db.models import Count -from django.shortcuts import get_object_or_404 -from rest_framework.decorators import action -from rest_framework.response import Response +from django.db.models import Prefetch +from rest_framework.routers import APIRootView from circuits import filters from circuits.models import Provider, CircuitTermination, CircuitType, Circuit -from extras.api.serializers import RenderedGraphSerializer +from dcim.api.views import PathEndpointMixin from extras.api.views import CustomFieldModelViewSet -from extras.models import Graph -from utilities.api import ModelViewSet +from netbox.api.views import ModelViewSet +from utilities.utils import count_related from . import serializers +class CircuitsRootView(APIRootView): + """ + Circuits API root view + """ + def get_view_name(self): + return 'Circuits' + + # # Providers # class ProviderViewSet(CustomFieldModelViewSet): queryset = Provider.objects.prefetch_related('tags').annotate( - circuit_count=Count('circuits') + circuit_count=count_related(Circuit, 'provider') ) serializer_class = serializers.ProviderSerializer filterset_class = filters.ProviderFilterSet - @action(detail=True) - def graphs(self, request, pk): - """ - A convenience method for rendering graphs for a particular provider. - """ - provider = get_object_or_404(Provider, pk=pk) - queryset = Graph.objects.filter(type__model='provider') - serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': provider}) - return Response(serializer.data) - # # Circuit Types @@ -40,7 +36,7 @@ class ProviderViewSet(CustomFieldModelViewSet): class CircuitTypeViewSet(ModelViewSet): queryset = CircuitType.objects.annotate( - circuit_count=Count('circuits') + circuit_count=count_related(Circuit, 'type') ) serializer_class = serializers.CircuitTypeSerializer filterset_class = filters.CircuitTypeFilterSet @@ -52,7 +48,8 @@ class CircuitTypeViewSet(ModelViewSet): class CircuitViewSet(CustomFieldModelViewSet): queryset = Circuit.objects.prefetch_related( - 'type', 'tenant', 'provider', 'terminations__site', 'terminations__connected_endpoint__device' + Prefetch('terminations', queryset=CircuitTermination.objects.prefetch_related('site')), + 'type', 'tenant', 'provider', ).prefetch_related('tags') serializer_class = serializers.CircuitSerializer filterset_class = filters.CircuitFilterSet @@ -62,9 +59,10 @@ class CircuitViewSet(CustomFieldModelViewSet): # Circuit Terminations # -class CircuitTerminationViewSet(ModelViewSet): +class CircuitTerminationViewSet(PathEndpointMixin, ModelViewSet): queryset = CircuitTermination.objects.prefetch_related( - 'circuit', 'site', 'connected_endpoint__device', 'cable' + 'circuit', 'site', '_path__destination', 'cable' ) serializer_class = serializers.CircuitTerminationSerializer filterset_class = filters.CircuitTerminationFilterSet + brief_prefetch_fields = ['circuit'] diff --git a/netbox/circuits/choices.py b/netbox/circuits/choices.py index 94a765d11..bbf536800 100644 --- a/netbox/circuits/choices.py +++ b/netbox/circuits/choices.py @@ -23,13 +23,13 @@ class CircuitStatusChoices(ChoiceSet): (STATUS_DECOMMISSIONED, 'Decommissioned'), ) - LEGACY_MAP = { - STATUS_DEPROVISIONING: 0, - STATUS_ACTIVE: 1, - STATUS_PLANNED: 2, - STATUS_PROVISIONING: 3, - STATUS_OFFLINE: 4, - STATUS_DECOMMISSIONED: 5, + CSS_CLASSES = { + STATUS_DEPROVISIONING: 'warning', + STATUS_ACTIVE: 'success', + STATUS_PLANNED: 'info', + STATUS_PROVISIONING: 'primary', + STATUS_OFFLINE: 'danger', + STATUS_DECOMMISSIONED: 'default', } diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index 206dcc305..fa563881c 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -1,8 +1,9 @@ import django_filters from django.db.models import Q +from dcim.filters import CableTerminationFilterSet, PathEndpointFilterSet from dcim.models import Region, Site -from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet +from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet from tenancy.filters import TenancyFilterSet from utilities.filters import ( BaseFilterSet, NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter @@ -18,7 +19,7 @@ __all__ = ( ) -class ProviderFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): +class ProviderFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -72,7 +73,7 @@ class CircuitTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet): fields = ['id', 'name', 'slug'] -class CircuitFilterSet(BaseFilterSet, CustomFieldFilterSet, TenancyFilterSet, CreatedUpdatedFilterSet): +class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSet, CreatedUpdatedFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -144,7 +145,7 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldFilterSet, TenancyFilterSet, Cr ).distinct() -class CircuitTerminationFilterSet(BaseFilterSet): +class CircuitTerminationFilterSet(BaseFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): q = django_filters.CharFilter( method='search', label='Search', diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 2185d1eab..4731c9adb 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -3,14 +3,14 @@ from django import forms from dcim.models import Region, Site from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm, - TagField, ) +from extras.models import Tag from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( - APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, CSVModelChoiceField, - CSVModelForm, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, SlugField, - StaticSelect2, StaticSelect2Multiple, TagFilterField, + add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, CSVModelChoiceField, CSVModelForm, DatePicker, + DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, SlugField, StaticSelect2, + StaticSelect2Multiple, TagFilterField, ) from .choices import CircuitStatusChoices from .models import Circuit, CircuitTermination, CircuitType, Provider @@ -23,7 +23,8 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider class ProviderForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() comments = CommentField() - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -105,21 +106,15 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', - required=False, - widget=APISelectMultiple( - value_field="slug", - filter_for={ - 'site': 'region' - } - ) + required=False ) site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', required=False, - widget=APISelectMultiple( - value_field="slug", - ) + query_params={ + 'region': '$region' + } ) asn = forms.IntegerField( required=False, @@ -165,7 +160,8 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): queryset=CircuitType.objects.all() ) comments = CommentField() - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -269,18 +265,12 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm type = DynamicModelMultipleChoiceField( queryset=CircuitType.objects.all(), to_field_name='slug', - required=False, - widget=APISelectMultiple( - value_field="slug", - ) + required=False ) provider = DynamicModelMultipleChoiceField( queryset=Provider.objects.all(), to_field_name='slug', - required=False, - widget=APISelectMultiple( - value_field="slug", - ) + required=False ) status = forms.MultipleChoiceField( choices=CircuitStatusChoices, @@ -290,21 +280,15 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', - required=False, - widget=APISelectMultiple( - value_field="slug", - filter_for={ - 'site': 'region' - } - ) + required=False ) site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', required=False, - widget=APISelectMultiple( - value_field="slug", - ) + query_params={ + 'region': '$region' + } ) commit_rate = forms.IntegerField( required=False, @@ -319,14 +303,24 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm # class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( - queryset=Site.objects.all() + queryset=Site.objects.all(), + query_params={ + 'region_id': '$region' + } ) class Meta: model = CircuitTermination fields = [ - 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', + 'term_side', 'region', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', ] help_texts = { 'port_speed': "Physical circuit speed", diff --git a/netbox/circuits/migrations/0019_nullbooleanfield_to_booleanfield.py b/netbox/circuits/migrations/0019_nullbooleanfield_to_booleanfield.py new file mode 100644 index 000000000..c8e844284 --- /dev/null +++ b/netbox/circuits/migrations/0019_nullbooleanfield_to_booleanfield.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1b1 on 2020-07-16 15:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0018_standardize_description'), + ] + + operations = [ + migrations.AlterField( + model_name='circuittermination', + name='connection_status', + field=models.BooleanField(blank=True, null=True), + ), + ] diff --git a/netbox/circuits/migrations/0020_custom_field_data.py b/netbox/circuits/migrations/0020_custom_field_data.py new file mode 100644 index 000000000..97da9962c --- /dev/null +++ b/netbox/circuits/migrations/0020_custom_field_data.py @@ -0,0 +1,22 @@ +import django.core.serializers.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0019_nullbooleanfield_to_booleanfield'), + ] + + operations = [ + migrations.AddField( + model_name='circuit', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='provider', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + ] diff --git a/netbox/circuits/migrations/0021_cache_cable_peer.py b/netbox/circuits/migrations/0021_cache_cable_peer.py new file mode 100644 index 000000000..630c3b4ec --- /dev/null +++ b/netbox/circuits/migrations/0021_cache_cable_peer.py @@ -0,0 +1,49 @@ +import sys + +from django.db import migrations, models +import django.db.models.deletion + + +def cache_cable_peers(apps, schema_editor): + ContentType = apps.get_model('contenttypes', 'ContentType') + Cable = apps.get_model('dcim', 'Cable') + CircuitTermination = apps.get_model('circuits', 'CircuitTermination') + + if 'test' not in sys.argv: + print(f"\n Updating circuit termination cable peers...", flush=True) + ct = ContentType.objects.get_for_model(CircuitTermination) + for cable in Cable.objects.filter(termination_a_type=ct): + CircuitTermination.objects.filter(pk=cable.termination_a_id).update( + _cable_peer_type_id=cable.termination_b_type_id, + _cable_peer_id=cable.termination_b_id + ) + for cable in Cable.objects.filter(termination_b_type=ct): + CircuitTermination.objects.filter(pk=cable.termination_b_id).update( + _cable_peer_type_id=cable.termination_a_type_id, + _cable_peer_id=cable.termination_a_id + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('circuits', '0020_custom_field_data'), + ] + + operations = [ + migrations.AddField( + model_name='circuittermination', + name='_cable_peer_id', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='circuittermination', + name='_cable_peer_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='contenttypes.contenttype'), + ), + migrations.RunPython( + code=cache_cable_peers, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/circuits/migrations/0022_cablepath.py b/netbox/circuits/migrations/0022_cablepath.py new file mode 100644 index 000000000..4a5b26efa --- /dev/null +++ b/netbox/circuits/migrations/0022_cablepath.py @@ -0,0 +1,26 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0121_cablepath'), + ('circuits', '0021_cache_cable_peer'), + ] + + operations = [ + migrations.AddField( + model_name='circuittermination', + name='_path', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.cablepath'), + ), + migrations.RemoveField( + model_name='circuittermination', + name='connected_endpoint', + ), + migrations.RemoveField( + model_name='circuittermination', + name='connection_status', + ), + ] diff --git a/netbox/circuits/migrations/0023_circuittermination_port_speed_optional.py b/netbox/circuits/migrations/0023_circuittermination_port_speed_optional.py new file mode 100644 index 000000000..ea9190623 --- /dev/null +++ b/netbox/circuits/migrations/0023_circuittermination_port_speed_optional.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1 on 2020-10-09 17:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0022_cablepath'), + ] + + operations = [ + migrations.AlterField( + model_name='circuittermination', + name='port_speed', + field=models.PositiveIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/circuits/migrations/0024_standardize_name_length.py b/netbox/circuits/migrations/0024_standardize_name_length.py new file mode 100644 index 000000000..8d0ae48e3 --- /dev/null +++ b/netbox/circuits/migrations/0024_standardize_name_length.py @@ -0,0 +1,38 @@ +# Generated by Django 3.1 on 2020-10-15 19:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0023_circuittermination_port_speed_optional'), + ] + + operations = [ + migrations.AlterField( + model_name='circuit', + name='cid', + field=models.CharField(max_length=100), + ), + migrations.AlterField( + model_name='circuittype', + name='name', + field=models.CharField(max_length=100, unique=True), + ), + migrations.AlterField( + model_name='circuittype', + name='slug', + field=models.SlugField(max_length=100, unique=True), + ), + migrations.AlterField( + model_name='provider', + name='name', + field=models.CharField(max_length=100, unique=True), + ), + migrations.AlterField( + model_name='provider', + name='slug', + field=models.SlugField(max_length=100, unique=True), + ), + ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 57d41a994..3d6d5d232 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -1,14 +1,12 @@ -from django.contrib.contenttypes.fields import GenericRelation from django.db import models from django.urls import reverse from taggit.managers import TaggableManager -from dcim.constants import CONNECTION_STATUS_CHOICES from dcim.fields import ASNField -from dcim.models import CableTermination -from extras.models import CustomFieldModel, ObjectChange, TaggedItem +from dcim.models import CableTermination, PathEndpoint +from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem from extras.utils import extras_features -from utilities.models import ChangeLoggedModel +from utilities.querysets import RestrictedQuerySet from utilities.utils import serialize_object from .choices import * from .querysets import CircuitQuerySet @@ -22,17 +20,18 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Provider(ChangeLoggedModel, CustomFieldModel): """ Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model stores information pertinent to the user's relationship with the Provider. """ name = models.CharField( - max_length=50, + max_length=100, unique=True ) slug = models.SlugField( + max_length=100, unique=True ) asn = ASNField( @@ -61,14 +60,10 @@ class Provider(ChangeLoggedModel, CustomFieldModel): comments = models.TextField( blank=True ) - custom_field_values = GenericRelation( - to='extras.CustomFieldValue', - content_type_field='obj_type', - object_id_field='obj_id' - ) - tags = TaggableManager(through=TaggedItem) + objects = RestrictedQuerySet.as_manager() + csv_headers = [ 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', ] @@ -104,10 +99,11 @@ class CircuitType(ChangeLoggedModel): "Long Haul," "Metro," or "Out-of-Band". """ name = models.CharField( - max_length=50, + max_length=100, unique=True ) slug = models.SlugField( + max_length=100, unique=True ) description = models.CharField( @@ -115,6 +111,8 @@ class CircuitType(ChangeLoggedModel): blank=True, ) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['name', 'slug', 'description'] class Meta: @@ -142,7 +140,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel): in Kbps. """ cid = models.CharField( - max_length=50, + max_length=100, verbose_name='Circuit ID' ) provider = models.ForeignKey( @@ -183,11 +181,6 @@ class Circuit(ChangeLoggedModel, CustomFieldModel): comments = models.TextField( blank=True ) - custom_field_values = GenericRelation( - to='extras.CustomFieldValue', - content_type_field='obj_type', - object_id_field='obj_id' - ) objects = CircuitQuerySet.as_manager() tags = TaggableManager(through=TaggedItem) @@ -199,15 +192,6 @@ class Circuit(ChangeLoggedModel, CustomFieldModel): 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', ] - STATUS_CLASS_MAP = { - CircuitStatusChoices.STATUS_DEPROVISIONING: 'warning', - CircuitStatusChoices.STATUS_ACTIVE: 'success', - CircuitStatusChoices.STATUS_PLANNED: 'info', - CircuitStatusChoices.STATUS_PROVISIONING: 'primary', - CircuitStatusChoices.STATUS_OFFLINE: 'danger', - CircuitStatusChoices.STATUS_DECOMMISSIONED: 'default', - } - class Meta: ordering = ['provider', 'cid'] unique_together = ['provider', 'cid'] @@ -232,7 +216,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel): ) def get_status_class(self): - return self.STATUS_CLASS_MAP.get(self.status) + return CircuitStatusChoices.CSS_CLASSES.get(self.status) def _get_termination(self, side): for ct in self.terminations.all(): @@ -249,7 +233,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel): return self._get_termination('Z') -class CircuitTermination(CableTermination): +class CircuitTermination(PathEndpoint, CableTermination): circuit = models.ForeignKey( to='circuits.Circuit', on_delete=models.CASCADE, @@ -265,20 +249,11 @@ class CircuitTermination(CableTermination): on_delete=models.PROTECT, related_name='circuit_terminations' ) - connected_endpoint = models.OneToOneField( - to='dcim.Interface', - on_delete=models.SET_NULL, - related_name='+', + port_speed = models.PositiveIntegerField( + verbose_name='Port speed (Kbps)', blank=True, null=True ) - connection_status = models.NullBooleanField( - choices=CONNECTION_STATUS_CHOICES, - blank=True - ) - port_speed = models.PositiveIntegerField( - verbose_name='Port speed (Kbps)' - ) upstream_speed = models.PositiveIntegerField( blank=True, null=True, @@ -300,6 +275,8 @@ class CircuitTermination(CableTermination): blank=True ) + objects = RestrictedQuerySet.as_manager() + class Meta: ordering = ['circuit', 'term_side'] unique_together = ['circuit', 'term_side'] @@ -330,6 +307,9 @@ class CircuitTermination(CableTermination): def get_peer_termination(self): peer_side = 'Z' if self.term_side == 'A' else 'A' try: - return CircuitTermination.objects.prefetch_related('site').get(circuit=self.circuit, term_side=peer_side) + return CircuitTermination.objects.prefetch_related('site').get( + circuit=self.circuit, + term_side=peer_side + ) except CircuitTermination.DoesNotExist: return None diff --git a/netbox/circuits/querysets.py b/netbox/circuits/querysets.py index 60956f32a..8a9bd50a4 100644 --- a/netbox/circuits/querysets.py +++ b/netbox/circuits/querysets.py @@ -1,7 +1,9 @@ -from django.db.models import OuterRef, QuerySet, Subquery +from django.db.models import OuterRef, Subquery + +from utilities.querysets import RestrictedQuerySet -class CircuitQuerySet(QuerySet): +class CircuitQuerySet(RestrictedQuerySet): def annotate_sites(self): """ diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index ea17031a1..782b02394 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -2,23 +2,9 @@ import django_tables2 as tables from django_tables2.utils import Accessor from tenancy.tables import COL_TENANT -from utilities.tables import BaseTable, TagColumn, ToggleColumn +from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, TagColumn, ToggleColumn from .models import Circuit, CircuitType, Provider -CIRCUITTYPE_ACTIONS = """ - - - -{% if perms.circuit.change_circuittype %} - -{% endif %} -""" - -STATUS_LABEL = """ -{{ record.get_status_display }} -""" - # # Providers @@ -53,11 +39,7 @@ class CircuitTypeTable(BaseTable): circuit_count = tables.Column( verbose_name='Circuits' ) - actions = tables.TemplateColumn( - template_code=CIRCUITTYPE_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' - ) + actions = ButtonsColumn(CircuitType, pk_field='slug') class Meta(BaseTable.Meta): model = CircuitType @@ -76,11 +58,9 @@ class CircuitTable(BaseTable): ) provider = tables.LinkColumn( viewname='circuits:provider', - args=[Accessor('provider.slug')] - ) - status = tables.TemplateColumn( - template_code=STATUS_LABEL + args=[Accessor('provider__slug')] ) + status = ChoiceFieldColumn() tenant = tables.TemplateColumn( template_code=COL_TENANT ) diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index b5f8758e7..6df931553 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -1,443 +1,180 @@ -from django.contrib.contenttypes.models import ContentType from django.urls import reverse -from rest_framework import status from circuits.choices import * from circuits.models import Circuit, CircuitTermination, CircuitType, Provider from dcim.models import Site -from extras.models import Graph -from utilities.testing import APITestCase +from utilities.testing import APITestCase, APIViewTestCases class AppTest(APITestCase): def test_root(self): - url = reverse('circuits-api:api-root') response = self.client.get('{}?format=api'.format(url), **self.header) self.assertEqual(response.status_code, 200) -class ProviderTest(APITestCase): +class ProviderTest(APIViewTestCases.APIViewTestCase): + model = Provider + brief_fields = ['circuit_count', 'id', 'name', 'slug', 'url'] + create_data = [ + { + 'name': 'Provider 4', + 'slug': 'provider-4', + }, + { + 'name': 'Provider 5', + 'slug': 'provider-5', + }, + { + 'name': 'Provider 6', + 'slug': 'provider-6', + }, + ] + bulk_update_data = { + 'asn': 1234, + } - def setUp(self): + @classmethod + def setUpTestData(cls): - 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') - self.provider3 = Provider.objects.create(name='Test Provider 3', slug='test-provider-3') - - def test_get_provider(self): - - url = reverse('circuits-api:provider-detail', kwargs={'pk': self.provider1.pk}) - response = self.client.get(url, **self.header) - - self.assertEqual(response.data['name'], self.provider1.name) - - def test_get_provider_graphs(self): - - provider_ct = ContentType.objects.get(app_label='circuits', model='provider') - self.graph1 = Graph.objects.create( - type=provider_ct, - name='Test Graph 1', - source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=1' + providers = ( + Provider(name='Provider 1', slug='provider-1'), + Provider(name='Provider 2', slug='provider-2'), + Provider(name='Provider 3', slug='provider-3'), ) - self.graph2 = Graph.objects.create( - type=provider_ct, - name='Test Graph 2', - source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=2' + Provider.objects.bulk_create(providers) + + +class CircuitTypeTest(APIViewTestCases.APIViewTestCase): + model = CircuitType + brief_fields = ['circuit_count', 'id', 'name', 'slug', 'url'] + create_data = ( + { + 'name': 'Circuit Type 4', + 'slug': 'circuit-type-4', + }, + { + 'name': 'Circuit Type 5', + 'slug': 'circuit-type-5', + }, + { + 'name': 'Circuit Type 6', + 'slug': 'circuit-type-6', + }, + ) + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + + circuit_types = ( + 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'), ) - self.graph3 = Graph.objects.create( - type=provider_ct, - name='Test Graph 3', - source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=3' + CircuitType.objects.bulk_create(circuit_types) + + +class CircuitTest(APIViewTestCases.APIViewTestCase): + model = Circuit + brief_fields = ['cid', 'id', 'url'] + bulk_update_data = { + 'status': 'planned', + } + + @classmethod + def setUpTestData(cls): + + providers = ( + Provider(name='Provider 1', slug='provider-1'), + Provider(name='Provider 2', slug='provider-2'), ) + Provider.objects.bulk_create(providers) - url = reverse('circuits-api:provider-graphs', kwargs={'pk': self.provider1.pk}) - response = self.client.get(url, **self.header) - - self.assertEqual(len(response.data), 3) - self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?provider=test-provider-1&foo=1') - - def test_list_providers(self): - - url = reverse('circuits-api:provider-list') - response = self.client.get(url, **self.header) - - self.assertEqual(response.data['count'], 3) - - def test_list_providers_brief(self): - - url = reverse('circuits-api:provider-list') - response = self.client.get('{}?brief=1'.format(url), **self.header) - - self.assertEqual( - sorted(response.data['results'][0]), - ['circuit_count', 'id', 'name', 'slug', 'url'] + circuit_types = ( + CircuitType(name='Circuit Type 1', slug='circuit-type-1'), + CircuitType(name='Circuit Type 2', slug='circuit-type-2'), ) + CircuitType.objects.bulk_create(circuit_types) - def test_create_provider(self): + circuits = ( + Circuit(cid='Circuit 1', provider=providers[0], type=circuit_types[0]), + Circuit(cid='Circuit 2', provider=providers[0], type=circuit_types[0]), + Circuit(cid='Circuit 3', provider=providers[0], type=circuit_types[0]), + ) + Circuit.objects.bulk_create(circuits) - data = { - 'name': 'Test Provider 4', - 'slug': 'test-provider-4', - } - - url = reverse('circuits-api:provider-list') - response = self.client.post(url, data, format='json', **self.header) - - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(Provider.objects.count(), 4) - provider4 = Provider.objects.get(pk=response.data['id']) - self.assertEqual(provider4.name, data['name']) - self.assertEqual(provider4.slug, data['slug']) - - def test_create_provider_bulk(self): - - data = [ + cls.create_data = [ { - 'name': 'Test Provider 4', - 'slug': 'test-provider-4', + 'cid': 'Circuit 4', + 'provider': providers[1].pk, + 'type': circuit_types[1].pk, }, { - 'name': 'Test Provider 5', - 'slug': 'test-provider-5', + 'cid': 'Circuit 5', + 'provider': providers[1].pk, + 'type': circuit_types[1].pk, }, { - 'name': 'Test Provider 6', - 'slug': 'test-provider-6', + 'cid': 'Circuit 6', + 'provider': providers[1].pk, + 'type': circuit_types[1].pk, }, ] - url = reverse('circuits-api:provider-list') - response = self.client.post(url, data, format='json', **self.header) - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(Provider.objects.count(), 6) - self.assertEqual(response.data[0]['name'], data[0]['name']) - self.assertEqual(response.data[1]['name'], data[1]['name']) - self.assertEqual(response.data[2]['name'], data[2]['name']) +class CircuitTerminationTest(APIViewTestCases.APIViewTestCase): + model = CircuitTermination + brief_fields = ['cable', 'circuit', 'id', 'term_side', 'url'] - def test_update_provider(self): + @classmethod + def setUpTestData(cls): + SIDE_A = CircuitTerminationSideChoices.SIDE_A + SIDE_Z = CircuitTerminationSideChoices.SIDE_Z - data = { - 'name': 'Test Provider X', - 'slug': 'test-provider-x', - } - - url = reverse('circuits-api:provider-detail', kwargs={'pk': self.provider1.pk}) - response = self.client.put(url, data, format='json', **self.header) - - self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(Provider.objects.count(), 3) - provider1 = Provider.objects.get(pk=response.data['id']) - self.assertEqual(provider1.name, data['name']) - self.assertEqual(provider1.slug, data['slug']) - - def test_delete_provider(self): - - url = reverse('circuits-api:provider-detail', kwargs={'pk': self.provider1.pk}) - response = self.client.delete(url, **self.header) - - self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) - self.assertEqual(Provider.objects.count(), 2) - - -class CircuitTypeTest(APITestCase): - - def setUp(self): - - 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') - self.circuittype3 = CircuitType.objects.create(name='Test Circuit Type 3', slug='test-circuit-type-3') - - def test_get_circuittype(self): - - url = reverse('circuits-api:circuittype-detail', kwargs={'pk': self.circuittype1.pk}) - response = self.client.get(url, **self.header) - - self.assertEqual(response.data['name'], self.circuittype1.name) - - def test_list_circuittypes(self): - - url = reverse('circuits-api:circuittype-list') - response = self.client.get(url, **self.header) - - self.assertEqual(response.data['count'], 3) - - def test_list_circuittypes_brief(self): - - url = reverse('circuits-api:circuittype-list') - response = self.client.get('{}?brief=1'.format(url), **self.header) - - self.assertEqual( - sorted(response.data['results'][0]), - ['circuit_count', 'id', 'name', 'slug', 'url'] + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), ) + Site.objects.bulk_create(sites) - def test_create_circuittype(self): + provider = Provider.objects.create(name='Provider 1', slug='provider-1') + circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1') - data = { - 'name': 'Test Circuit Type 4', - 'slug': 'test-circuit-type-4', - } - - url = reverse('circuits-api:circuittype-list') - response = self.client.post(url, data, format='json', **self.header) - - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(CircuitType.objects.count(), 4) - circuittype4 = CircuitType.objects.get(pk=response.data['id']) - self.assertEqual(circuittype4.name, data['name']) - self.assertEqual(circuittype4.slug, data['slug']) - - def test_update_circuittype(self): - - data = { - 'name': 'Test Circuit Type X', - 'slug': 'test-circuit-type-x', - } - - url = reverse('circuits-api:circuittype-detail', kwargs={'pk': self.circuittype1.pk}) - response = self.client.put(url, data, format='json', **self.header) - - self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(CircuitType.objects.count(), 3) - circuittype1 = CircuitType.objects.get(pk=response.data['id']) - self.assertEqual(circuittype1.name, data['name']) - self.assertEqual(circuittype1.slug, data['slug']) - - def test_delete_circuittype(self): - - url = reverse('circuits-api:circuittype-detail', kwargs={'pk': self.circuittype1.pk}) - response = self.client.delete(url, **self.header) - - self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) - self.assertEqual(CircuitType.objects.count(), 2) - - -class CircuitTest(APITestCase): - - def setUp(self): - - 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') - 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') - self.circuit1 = Circuit.objects.create(cid='TEST0001', provider=self.provider1, type=self.circuittype1) - self.circuit2 = Circuit.objects.create(cid='TEST0002', provider=self.provider1, type=self.circuittype1) - self.circuit3 = Circuit.objects.create(cid='TEST0003', provider=self.provider1, type=self.circuittype1) - - def test_get_circuit(self): - - url = reverse('circuits-api:circuit-detail', kwargs={'pk': self.circuit1.pk}) - response = self.client.get(url, **self.header) - - self.assertEqual(response.data['cid'], self.circuit1.cid) - - def test_list_circuits(self): - - url = reverse('circuits-api:circuit-list') - response = self.client.get(url, **self.header) - - self.assertEqual(response.data['count'], 3) - - def test_list_circuits_brief(self): - - url = reverse('circuits-api:circuit-list') - response = self.client.get('{}?brief=1'.format(url), **self.header) - - self.assertEqual( - sorted(response.data['results'][0]), - ['cid', 'id', 'url'] + circuits = ( + Circuit(cid='Circuit 1', provider=provider, type=circuit_type), + Circuit(cid='Circuit 2', provider=provider, type=circuit_type), + Circuit(cid='Circuit 3', provider=provider, type=circuit_type), ) + Circuit.objects.bulk_create(circuits) - def test_create_circuit(self): + circuit_terminations = ( + CircuitTermination(circuit=circuits[0], site=sites[0], term_side=SIDE_A), + CircuitTermination(circuit=circuits[0], site=sites[1], term_side=SIDE_Z), + CircuitTermination(circuit=circuits[1], site=sites[0], term_side=SIDE_A), + CircuitTermination(circuit=circuits[1], site=sites[1], term_side=SIDE_Z), + ) + CircuitTermination.objects.bulk_create(circuit_terminations) - data = { - 'cid': 'TEST0004', - 'provider': self.provider1.pk, - 'type': self.circuittype1.pk, - 'status': CircuitStatusChoices.STATUS_ACTIVE, - } - - url = reverse('circuits-api:circuit-list') - response = self.client.post(url, data, format='json', **self.header) - - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(Circuit.objects.count(), 4) - circuit4 = Circuit.objects.get(pk=response.data['id']) - self.assertEqual(circuit4.cid, data['cid']) - self.assertEqual(circuit4.provider_id, data['provider']) - self.assertEqual(circuit4.type_id, data['type']) - - def test_create_circuit_bulk(self): - - data = [ + cls.create_data = [ { - 'cid': 'TEST0004', - 'provider': self.provider1.pk, - 'type': self.circuittype1.pk, - 'status': CircuitStatusChoices.STATUS_ACTIVE, + 'circuit': circuits[2].pk, + 'term_side': SIDE_A, + 'site': sites[1].pk, + 'port_speed': 200000, }, { - 'cid': 'TEST0005', - 'provider': self.provider1.pk, - 'type': self.circuittype1.pk, - 'status': CircuitStatusChoices.STATUS_ACTIVE, - }, - { - 'cid': 'TEST0006', - 'provider': self.provider1.pk, - 'type': self.circuittype1.pk, - 'status': CircuitStatusChoices.STATUS_ACTIVE, + 'circuit': circuits[2].pk, + 'term_side': SIDE_Z, + 'site': sites[1].pk, + 'port_speed': 200000, }, ] - url = reverse('circuits-api:circuit-list') - response = self.client.post(url, data, format='json', **self.header) - - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(Circuit.objects.count(), 6) - self.assertEqual(response.data[0]['cid'], data[0]['cid']) - self.assertEqual(response.data[1]['cid'], data[1]['cid']) - self.assertEqual(response.data[2]['cid'], data[2]['cid']) - - def test_update_circuit(self): - - data = { - 'cid': 'TEST000X', - 'provider': self.provider2.pk, - 'type': self.circuittype2.pk, + cls.bulk_update_data = { + 'port_speed': 123456 } - - url = reverse('circuits-api:circuit-detail', kwargs={'pk': self.circuit1.pk}) - response = self.client.put(url, data, format='json', **self.header) - - self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(Circuit.objects.count(), 3) - circuit1 = Circuit.objects.get(pk=response.data['id']) - self.assertEqual(circuit1.cid, data['cid']) - self.assertEqual(circuit1.provider_id, data['provider']) - self.assertEqual(circuit1.type_id, data['type']) - - def test_delete_circuit(self): - - url = reverse('circuits-api:circuit-detail', kwargs={'pk': self.circuit1.pk}) - response = self.client.delete(url, **self.header) - - self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) - self.assertEqual(Circuit.objects.count(), 2) - - -class CircuitTerminationTest(APITestCase): - - def setUp(self): - - super().setUp() - - self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') - self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2') - provider = Provider.objects.create(name='Test Provider', slug='test-provider') - circuittype = CircuitType.objects.create(name='Test Circuit Type', slug='test-circuit-type') - self.circuit1 = Circuit.objects.create(cid='TEST0001', provider=provider, type=circuittype) - self.circuit2 = Circuit.objects.create(cid='TEST0002', provider=provider, type=circuittype) - self.circuit3 = Circuit.objects.create(cid='TEST0003', provider=provider, type=circuittype) - self.circuittermination1 = CircuitTermination.objects.create( - circuit=self.circuit1, - term_side=CircuitTerminationSideChoices.SIDE_A, - site=self.site1, - port_speed=1000000 - ) - self.circuittermination2 = CircuitTermination.objects.create( - circuit=self.circuit1, - term_side=CircuitTerminationSideChoices.SIDE_Z, - site=self.site2, - port_speed=1000000 - ) - self.circuittermination3 = CircuitTermination.objects.create( - circuit=self.circuit2, - term_side=CircuitTerminationSideChoices.SIDE_A, - site=self.site1, - port_speed=1000000 - ) - self.circuittermination4 = CircuitTermination.objects.create( - circuit=self.circuit2, - term_side=CircuitTerminationSideChoices.SIDE_Z, - site=self.site2, - port_speed=1000000 - ) - - def test_get_circuittermination(self): - - url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': self.circuittermination1.pk}) - response = self.client.get(url, **self.header) - - self.assertEqual(response.data['id'], self.circuittermination1.pk) - - def test_list_circuitterminations(self): - - url = reverse('circuits-api:circuittermination-list') - response = self.client.get(url, **self.header) - - self.assertEqual(response.data['count'], 4) - - def test_create_circuittermination(self): - - data = { - 'circuit': self.circuit3.pk, - 'term_side': CircuitTerminationSideChoices.SIDE_A, - 'site': self.site1.pk, - 'port_speed': 1000000, - } - - url = reverse('circuits-api:circuittermination-list') - response = self.client.post(url, data, format='json', **self.header) - - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(CircuitTermination.objects.count(), 5) - circuittermination4 = CircuitTermination.objects.get(pk=response.data['id']) - self.assertEqual(circuittermination4.circuit_id, data['circuit']) - self.assertEqual(circuittermination4.term_side, data['term_side']) - self.assertEqual(circuittermination4.site_id, data['site']) - self.assertEqual(circuittermination4.port_speed, data['port_speed']) - - def test_update_circuittermination(self): - - circuittermination5 = CircuitTermination.objects.create( - circuit=self.circuit3, - term_side=CircuitTerminationSideChoices.SIDE_A, - site=self.site1, - port_speed=1000000 - ) - - data = { - 'circuit': self.circuit3.pk, - 'term_side': CircuitTerminationSideChoices.SIDE_Z, - 'site': self.site2.pk, - 'port_speed': 1000000, - } - - url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': circuittermination5.pk}) - response = self.client.put(url, data, format='json', **self.header) - - self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(CircuitTermination.objects.count(), 5) - 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.port_speed, data['port_speed']) - - def test_delete_circuittermination(self): - - url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': self.circuittermination1.pk}) - response = self.client.delete(url, **self.header) - - self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) - self.assertEqual(CircuitTermination.objects.count(), 3) diff --git a/netbox/circuits/tests/test_filters.py b/netbox/circuits/tests/test_filters.py index 9756c320b..9477bfbac 100644 --- a/netbox/circuits/tests/test_filters.py +++ b/netbox/circuits/tests/test_filters.py @@ -3,7 +3,7 @@ from django.test import TestCase from circuits.choices import * from circuits.filters import * from circuits.models import Circuit, CircuitTermination, CircuitType, Provider -from dcim.models import Region, Site +from dcim.models import Cable, Region, Site from tenancy.models import Tenant, TenantGroup @@ -50,8 +50,8 @@ class ProviderTestCase(TestCase): Circuit.objects.bulk_create(circuits) CircuitTermination.objects.bulk_create(( - CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A', port_speed=1000), - CircuitTermination(circuit=circuits[1], site=sites[0], term_side='A', port_speed=1000), + CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A'), + CircuitTermination(circuit=circuits[1], site=sites[0], term_side='A'), )) def test_id(self): @@ -176,9 +176,9 @@ class CircuitTestCase(TestCase): Circuit.objects.bulk_create(circuits) circuit_terminations = (( - CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A', port_speed=1000), - CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A', port_speed=1000), - CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A', port_speed=1000), + CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A'), + CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A'), + CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A'), )) CircuitTermination.objects.bulk_create(circuit_terminations) @@ -286,6 +286,8 @@ class CircuitTerminationTestCase(TestCase): )) CircuitTermination.objects.bulk_create(circuit_terminations) + Cable(termination_a=circuit_terminations[0], termination_b=circuit_terminations[1]).save() + def test_term_side(self): params = {'term_side': 'A'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) @@ -313,3 +315,13 @@ class CircuitTerminationTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) params = {'site': [sites[0].slug, sites[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_cabled(self): + params = {'cabled': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_connected(self): + params = {'connected': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'connected': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index 9cc7af6ae..3356fca8f 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -17,6 +17,8 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase): Provider(name='Provider 3', slug='provider-3', asn=65003), ]) + tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Provider X', 'slug': 'provider-x', @@ -26,7 +28,7 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'noc_contact': 'noc@example.com', 'admin_contact': 'admin@example.com', 'comments': 'Another provider', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -96,6 +98,8 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): Circuit(cid='Circuit 3', provider=providers[0], type=circuittypes[0]), ]) + tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'cid': 'Circuit X', 'provider': providers[1].pk, @@ -106,7 +110,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'commit_rate': 1000, 'description': 'A new circuit', 'comments': 'Some comments', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -124,5 +128,4 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'commit_rate': 2000, 'description': 'New description', 'comments': 'New comments', - } diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index 72d9720df..d757fd90d 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from dcim.views import CableCreateView, CableTraceView +from dcim.views import CableCreateView, PathTraceView from extras.views import ObjectChangeLogView from . import views from .models import Circuit, CircuitTermination, CircuitType, Provider @@ -10,7 +10,7 @@ urlpatterns = [ # Providers path('providers/', views.ProviderListView.as_view(), name='provider_list'), - path('providers/add/', views.ProviderCreateView.as_view(), name='provider_add'), + path('providers/add/', views.ProviderEditView.as_view(), name='provider_add'), path('providers/import/', views.ProviderBulkImportView.as_view(), name='provider_import'), path('providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'), path('providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'), @@ -21,15 +21,16 @@ urlpatterns = [ # Circuit types path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'), - path('circuit-types/add/', views.CircuitTypeCreateView.as_view(), name='circuittype_add'), + path('circuit-types/add/', views.CircuitTypeEditView.as_view(), name='circuittype_add'), path('circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'), path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'), path('circuit-types//edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'), + path('circuit-types//delete/', views.CircuitTypeDeleteView.as_view(), name='circuittype_delete'), path('circuit-types//changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}), # Circuits path('circuits/', views.CircuitListView.as_view(), name='circuit_list'), - path('circuits/add/', views.CircuitCreateView.as_view(), name='circuit_add'), + path('circuits/add/', views.CircuitEditView.as_view(), name='circuit_add'), path('circuits/import/', views.CircuitBulkImportView.as_view(), name='circuit_import'), path('circuits/edit/', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'), path('circuits/delete/', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'), @@ -37,14 +38,13 @@ urlpatterns = [ path('circuits//edit/', views.CircuitEditView.as_view(), name='circuit_edit'), path('circuits//delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'), path('circuits//changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}), - path('circuits//terminations/swap/', views.circuit_terminations_swap, name='circuit_terminations_swap'), + path('circuits//terminations/swap/', views.CircuitSwapTerminations.as_view(), name='circuit_terminations_swap'), # Circuit terminations - - path('circuits//terminations/add/', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'), + path('circuits//terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'), path('circuit-terminations//edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'), path('circuit-terminations//delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'), path('circuit-terminations//connect//', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}), - path('circuit-terminations//trace/', CableTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}), + path('circuit-terminations//trace/', PathTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}), ] diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 709d2a726..9fea26652 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -1,19 +1,12 @@ -from django.conf import settings from django.contrib import messages -from django.contrib.auth.decorators import permission_required -from django.contrib.auth.mixins import PermissionRequiredMixin from django.db import transaction -from django.db.models import Count, OuterRef, Subquery from django.shortcuts import get_object_or_404, redirect, render -from django.views.generic import View from django_tables2 import RequestConfig -from extras.models import Graph +from netbox.views import generic from utilities.forms import ConfirmationForm -from utilities.paginator import EnhancedPaginator -from utilities.views import ( - BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, -) +from utilities.paginator import EnhancedPaginator, get_paginate_count +from utilities.utils import count_related from . import filters, forms, tables from .choices import CircuitTerminationSideChoices from .models import Circuit, CircuitTermination, CircuitType, Provider @@ -23,220 +16,221 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider # Providers # -class ProviderListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'circuits.view_provider' - queryset = Provider.objects.annotate(count_circuits=Count('circuits')) +class ProviderListView(generic.ObjectListView): + queryset = Provider.objects.annotate( + count_circuits=count_related(Circuit, 'provider') + ) filterset = filters.ProviderFilterSet filterset_form = forms.ProviderFilterForm table = tables.ProviderTable -class ProviderView(PermissionRequiredMixin, View): - permission_required = 'circuits.view_provider' +class ProviderView(generic.ObjectView): + queryset = Provider.objects.all() - def get(self, request, slug): - - provider = get_object_or_404(Provider, slug=slug) - circuits = Circuit.objects.filter( - provider=provider + def get_extra_context(self, request, instance): + circuits = Circuit.objects.restrict(request.user, 'view').filter( + provider=instance ).prefetch_related( 'type', 'tenant', 'terminations__site' ).annotate_sites() - show_graphs = Graph.objects.filter(type__model='provider').exists() circuits_table = tables.CircuitTable(circuits) circuits_table.columns.hide('provider') paginate = { 'paginator_class': EnhancedPaginator, - 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) + 'per_page': get_paginate_count(request) } RequestConfig(request, paginate).configure(circuits_table) - return render(request, 'circuits/provider.html', { - 'provider': provider, + return { 'circuits_table': circuits_table, - 'show_graphs': show_graphs, - }) + } -class ProviderCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'circuits.add_provider' - model = Provider +class ProviderEditView(generic.ObjectEditView): + queryset = Provider.objects.all() model_form = forms.ProviderForm template_name = 'circuits/provider_edit.html' - default_return_url = 'circuits:provider_list' -class ProviderEditView(ProviderCreateView): - permission_required = 'circuits.change_provider' +class ProviderDeleteView(generic.ObjectDeleteView): + queryset = Provider.objects.all() -class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'circuits.delete_provider' - model = Provider - default_return_url = 'circuits:provider_list' - - -class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'circuits.add_provider' +class ProviderBulkImportView(generic.BulkImportView): + queryset = Provider.objects.all() model_form = forms.ProviderCSVForm table = tables.ProviderTable - default_return_url = 'circuits:provider_list' -class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'circuits.change_provider' - queryset = Provider.objects.annotate(count_circuits=Count('circuits')) +class ProviderBulkEditView(generic.BulkEditView): + queryset = Provider.objects.annotate( + count_circuits=count_related(Circuit, 'provider') + ) filterset = filters.ProviderFilterSet table = tables.ProviderTable form = forms.ProviderBulkEditForm - default_return_url = 'circuits:provider_list' -class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'circuits.delete_provider' - queryset = Provider.objects.annotate(count_circuits=Count('circuits')) +class ProviderBulkDeleteView(generic.BulkDeleteView): + queryset = Provider.objects.annotate( + count_circuits=count_related(Circuit, 'provider') + ) filterset = filters.ProviderFilterSet table = tables.ProviderTable - default_return_url = 'circuits:provider_list' # # Circuit Types # -class CircuitTypeListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'circuits.view_circuittype' - queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')) +class CircuitTypeListView(generic.ObjectListView): + queryset = CircuitType.objects.annotate( + circuit_count=count_related(Circuit, 'type') + ) table = tables.CircuitTypeTable -class CircuitTypeCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'circuits.add_circuittype' - model = CircuitType +class CircuitTypeEditView(generic.ObjectEditView): + queryset = CircuitType.objects.all() model_form = forms.CircuitTypeForm - default_return_url = 'circuits:circuittype_list' -class CircuitTypeEditView(CircuitTypeCreateView): - permission_required = 'circuits.change_circuittype' +class CircuitTypeDeleteView(generic.ObjectDeleteView): + queryset = CircuitType.objects.all() -class CircuitTypeBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'circuits.add_circuittype' +class CircuitTypeBulkImportView(generic.BulkImportView): + queryset = CircuitType.objects.all() model_form = forms.CircuitTypeCSVForm table = tables.CircuitTypeTable - default_return_url = 'circuits:circuittype_list' -class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'circuits.delete_circuittype' - queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')) +class CircuitTypeBulkDeleteView(generic.BulkDeleteView): + queryset = CircuitType.objects.annotate( + circuit_count=count_related(Circuit, 'type') + ) table = tables.CircuitTypeTable - default_return_url = 'circuits:circuittype_list' # # Circuits # -class CircuitListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'circuits.view_circuit' - _terminations = CircuitTermination.objects.filter(circuit=OuterRef('pk')) +class CircuitListView(generic.ObjectListView): queryset = Circuit.objects.prefetch_related( - 'provider', 'type', 'tenant', 'terminations__site' + 'provider', 'type', 'tenant', 'terminations' ).annotate_sites() filterset = filters.CircuitFilterSet filterset_form = forms.CircuitFilterForm table = tables.CircuitTable -class CircuitView(PermissionRequiredMixin, View): - permission_required = 'circuits.view_circuit' +class CircuitView(generic.ObjectView): + queryset = Circuit.objects.all() - def get(self, request, pk): + def get_extra_context(self, request, instance): - circuit = get_object_or_404(Circuit.objects.prefetch_related('provider', 'type', 'tenant__group'), pk=pk) - termination_a = CircuitTermination.objects.prefetch_related( - 'site__region', 'connected_endpoint__device' + # A-side termination + termination_a = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related( + 'site__region' ).filter( - circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A - ).first() - termination_z = CircuitTermination.objects.prefetch_related( - 'site__region', 'connected_endpoint__device' - ).filter( - circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z + circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_A ).first() + if termination_a and termination_a.connected_endpoint and hasattr(termination_a.connected_endpoint, 'ip_addresses'): + termination_a.ip_addresses = termination_a.connected_endpoint.ip_addresses.restrict(request.user, 'view') - return render(request, 'circuits/circuit.html', { - 'circuit': circuit, + # Z-side termination + termination_z = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related( + 'site__region' + ).filter( + circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_Z + ).first() + if termination_z and termination_z.connected_endpoint and hasattr(termination_z.connected_endpoint, 'ip_addresses'): + termination_z.ip_addresses = termination_z.connected_endpoint.ip_addresses.restrict(request.user, 'view') + + return { 'termination_a': termination_a, 'termination_z': termination_z, - }) + } -class CircuitCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'circuits.add_circuit' - model = Circuit +class CircuitEditView(generic.ObjectEditView): + queryset = Circuit.objects.all() model_form = forms.CircuitForm template_name = 'circuits/circuit_edit.html' - default_return_url = 'circuits:circuit_list' -class CircuitEditView(CircuitCreateView): - permission_required = 'circuits.change_circuit' +class CircuitDeleteView(generic.ObjectDeleteView): + queryset = Circuit.objects.all() -class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'circuits.delete_circuit' - model = Circuit - default_return_url = 'circuits:circuit_list' - - -class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'circuits.add_circuit' +class CircuitBulkImportView(generic.BulkImportView): + queryset = Circuit.objects.all() model_form = forms.CircuitCSVForm table = tables.CircuitTable - default_return_url = 'circuits:circuit_list' -class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'circuits.change_circuit' - queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant').prefetch_related('terminations__site') +class CircuitBulkEditView(generic.BulkEditView): + queryset = Circuit.objects.prefetch_related( + 'provider', 'type', 'tenant', 'terminations' + ) filterset = filters.CircuitFilterSet table = tables.CircuitTable form = forms.CircuitBulkEditForm - default_return_url = 'circuits:circuit_list' -class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'circuits.delete_circuit' - queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant').prefetch_related('terminations__site') +class CircuitBulkDeleteView(generic.BulkDeleteView): + queryset = Circuit.objects.prefetch_related( + 'provider', 'type', 'tenant', 'terminations' + ) filterset = filters.CircuitFilterSet table = tables.CircuitTable - default_return_url = 'circuits:circuit_list' -@permission_required('circuits.change_circuittermination') -def circuit_terminations_swap(request, pk): +class CircuitSwapTerminations(generic.ObjectEditView): + """ + Swap the A and Z terminations of a circuit. + """ + queryset = Circuit.objects.all() - circuit = get_object_or_404(Circuit, pk=pk) - termination_a = CircuitTermination.objects.filter( - circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A - ).first() - termination_z = CircuitTermination.objects.filter( - circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z - ).first() - if not termination_a and not termination_z: - messages.error(request, "No terminations have been defined for circuit {}.".format(circuit)) - return redirect('circuits:circuit', pk=circuit.pk) + def get(self, request, pk): + circuit = get_object_or_404(self.queryset, pk=pk) + form = ConfirmationForm() - if request.method == 'POST': + # Circuit must have at least one termination to swap + if not circuit.termination_a and not circuit.termination_z: + messages.error(request, "No terminations have been defined for circuit {}.".format(circuit)) + return redirect('circuits:circuit', pk=circuit.pk) + + return render(request, 'circuits/circuit_terminations_swap.html', { + 'circuit': circuit, + 'termination_a': circuit.termination_a, + 'termination_z': circuit.termination_z, + 'form': form, + 'panel_class': 'default', + 'button_class': 'primary', + 'return_url': circuit.get_absolute_url(), + }) + + def post(self, request, pk): + circuit = get_object_or_404(self.queryset, pk=pk) form = ConfirmationForm(request.POST) + if form.is_valid(): + + termination_a = CircuitTermination.objects.filter( + circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A + ).first() + termination_z = CircuitTermination.objects.filter( + circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z + ).first() + if termination_a and termination_z: # Use a placeholder to avoid an IntegrityError on the (circuit, term_side) unique constraint + print('swapping') with transaction.atomic(): termination_a.term_side = '_' termination_a.save() @@ -250,30 +244,27 @@ def circuit_terminations_swap(request, pk): else: termination_z.term_side = 'A' termination_z.save() + messages.success(request, "Swapped terminations for circuit {}.".format(circuit)) return redirect('circuits:circuit', pk=circuit.pk) - else: - form = ConfirmationForm() - - return render(request, 'circuits/circuit_terminations_swap.html', { - 'circuit': circuit, - 'termination_a': termination_a, - 'termination_z': termination_z, - 'form': form, - 'panel_class': 'default', - 'button_class': 'primary', - 'return_url': circuit.get_absolute_url(), - }) + return render(request, 'circuits/circuit_terminations_swap.html', { + 'circuit': circuit, + 'termination_a': circuit.termination_a, + 'termination_z': circuit.termination_z, + 'form': form, + 'panel_class': 'default', + 'button_class': 'primary', + 'return_url': circuit.get_absolute_url(), + }) # # Circuit terminations # -class CircuitTerminationCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'circuits.add_circuittermination' - model = CircuitTermination +class CircuitTerminationEditView(generic.ObjectEditView): + queryset = CircuitTermination.objects.all() model_form = forms.CircuitTerminationForm template_name = 'circuits/circuittermination_edit.html' @@ -286,10 +277,5 @@ class CircuitTerminationCreateView(PermissionRequiredMixin, ObjectEditView): return obj.circuit.get_absolute_url() -class CircuitTerminationEditView(CircuitTerminationCreateView): - permission_required = 'circuits.change_circuittermination' - - -class CircuitTerminationDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'circuits.delete_circuittermination' - model = CircuitTermination +class CircuitTerminationDeleteView(generic.ObjectDeleteView): + queryset = CircuitTermination.objects.all() diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index 5afca0664..d63d32d68 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -1,33 +1,34 @@ from rest_framework import serializers -from dcim.choices import InterfaceTypeChoices -from dcim.constants import CONNECTION_STATUS_CHOICES -from dcim.models import ( - Cable, ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceType, DeviceRole, FrontPort, FrontPortTemplate, - Interface, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerPanel, PowerPort, PowerPortTemplate, Rack, - RackGroup, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, -) -from utilities.api import ChoiceField, WritableNestedSerializer +from dcim import models +from netbox.api import WritableNestedSerializer __all__ = [ 'NestedCableSerializer', 'NestedConsolePortSerializer', + 'NestedConsolePortTemplateSerializer', 'NestedConsoleServerPortSerializer', + 'NestedConsoleServerPortTemplateSerializer', 'NestedDeviceBaySerializer', + 'NestedDeviceBayTemplateSerializer', 'NestedDeviceRoleSerializer', 'NestedDeviceSerializer', 'NestedDeviceTypeSerializer', 'NestedFrontPortSerializer', 'NestedFrontPortTemplateSerializer', 'NestedInterfaceSerializer', + 'NestedInterfaceTemplateSerializer', + 'NestedInventoryItemSerializer', 'NestedManufacturerSerializer', 'NestedPlatformSerializer', 'NestedPowerFeedSerializer', 'NestedPowerOutletSerializer', + 'NestedPowerOutletTemplateSerializer', 'NestedPowerPanelSerializer', 'NestedPowerPortSerializer', 'NestedPowerPortTemplateSerializer', 'NestedRackGroupSerializer', + 'NestedRackReservationSerializer', 'NestedRackRoleSerializer', 'NestedRackSerializer', 'NestedRearPortSerializer', @@ -45,17 +46,18 @@ __all__ = [ class NestedRegionSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail') site_count = serializers.IntegerField(read_only=True) + _depth = serializers.IntegerField(source='level', read_only=True) class Meta: - model = Region - fields = ['id', 'url', 'name', 'slug', 'site_count'] + model = models.Region + fields = ['id', 'url', 'name', 'slug', 'site_count', '_depth'] class NestedSiteSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail') class Meta: - model = Site + model = models.Site fields = ['id', 'url', 'name', 'slug'] @@ -66,10 +68,11 @@ class NestedSiteSerializer(WritableNestedSerializer): class NestedRackGroupSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail') rack_count = serializers.IntegerField(read_only=True) + _depth = serializers.IntegerField(source='level', read_only=True) class Meta: - model = RackGroup - fields = ['id', 'url', 'name', 'slug', 'rack_count'] + model = models.RackGroup + fields = ['id', 'url', 'name', 'slug', 'rack_count', '_depth'] class NestedRackRoleSerializer(WritableNestedSerializer): @@ -77,7 +80,7 @@ class NestedRackRoleSerializer(WritableNestedSerializer): rack_count = serializers.IntegerField(read_only=True) class Meta: - model = RackRole + model = models.RackRole fields = ['id', 'url', 'name', 'slug', 'rack_count'] @@ -86,10 +89,22 @@ class NestedRackSerializer(WritableNestedSerializer): device_count = serializers.IntegerField(read_only=True) class Meta: - model = Rack + model = models.Rack fields = ['id', 'url', 'name', 'display_name', 'device_count'] +class NestedRackReservationSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail') + user = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = models.RackReservation + fields = ['id', 'url', 'user', 'units'] + + def get_user(self, obj): + return obj.user.username + + # # Device types # @@ -99,7 +114,7 @@ class NestedManufacturerSerializer(WritableNestedSerializer): devicetype_count = serializers.IntegerField(read_only=True) class Meta: - model = Manufacturer + model = models.Manufacturer fields = ['id', 'url', 'name', 'slug', 'devicetype_count'] @@ -109,15 +124,47 @@ class NestedDeviceTypeSerializer(WritableNestedSerializer): device_count = serializers.IntegerField(read_only=True) class Meta: - model = DeviceType + model = models.DeviceType fields = ['id', 'url', 'manufacturer', 'model', 'slug', 'display_name', 'device_count'] +class NestedConsolePortTemplateSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail') + + class Meta: + model = models.ConsolePortTemplate + fields = ['id', 'url', 'name'] + + +class NestedConsoleServerPortTemplateSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverporttemplate-detail') + + class Meta: + model = models.ConsoleServerPortTemplate + fields = ['id', 'url', 'name'] + + class NestedPowerPortTemplateSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerporttemplate-detail') class Meta: - model = PowerPortTemplate + model = models.PowerPortTemplate + fields = ['id', 'url', 'name'] + + +class NestedPowerOutletTemplateSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlettemplate-detail') + + class Meta: + model = models.PowerOutletTemplate + fields = ['id', 'url', 'name'] + + +class NestedInterfaceTemplateSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfacetemplate-detail') + + class Meta: + model = models.InterfaceTemplate fields = ['id', 'url', 'name'] @@ -125,7 +172,7 @@ class NestedRearPortTemplateSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearporttemplate-detail') class Meta: - model = RearPortTemplate + model = models.RearPortTemplate fields = ['id', 'url', 'name'] @@ -133,7 +180,15 @@ class NestedFrontPortTemplateSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontporttemplate-detail') class Meta: - model = FrontPortTemplate + model = models.FrontPortTemplate + fields = ['id', 'url', 'name'] + + +class NestedDeviceBayTemplateSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebaytemplate-detail') + + class Meta: + model = models.DeviceBayTemplate fields = ['id', 'url', 'name'] @@ -147,7 +202,7 @@ class NestedDeviceRoleSerializer(WritableNestedSerializer): virtualmachine_count = serializers.IntegerField(read_only=True) class Meta: - model = DeviceRole + model = models.DeviceRole fields = ['id', 'url', 'name', 'slug', 'device_count', 'virtualmachine_count'] @@ -157,7 +212,7 @@ class NestedPlatformSerializer(WritableNestedSerializer): virtualmachine_count = serializers.IntegerField(read_only=True) class Meta: - model = Platform + model = models.Platform fields = ['id', 'url', 'name', 'slug', 'device_count', 'virtualmachine_count'] @@ -165,59 +220,53 @@ class NestedDeviceSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') class Meta: - model = Device + model = models.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'] + model = models.ConsoleServerPort + fields = ['id', 'url', 'device', 'name', 'cable'] 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'] + model = models.ConsolePort + fields = ['id', 'url', 'device', 'name', 'cable'] 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'] + model = models.PowerOutlet + fields = ['id', 'url', 'device', 'name', 'cable'] 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'] + model = models.PowerPort + fields = ['id', 'url', 'device', 'name', 'cable'] 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) - type = ChoiceField(choices=InterfaceTypeChoices) class Meta: - model = Interface - fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status', 'type'] + model = models.Interface + fields = ['id', 'url', 'device', 'name', 'cable'] class NestedRearPortSerializer(WritableNestedSerializer): @@ -225,7 +274,7 @@ class NestedRearPortSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') class Meta: - model = RearPort + model = models.RearPort fields = ['id', 'url', 'device', 'name', 'cable'] @@ -234,7 +283,7 @@ class NestedFrontPortSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail') class Meta: - model = FrontPort + model = models.FrontPort fields = ['id', 'url', 'device', 'name', 'cable'] @@ -243,10 +292,20 @@ class NestedDeviceBaySerializer(WritableNestedSerializer): device = NestedDeviceSerializer(read_only=True) class Meta: - model = DeviceBay + model = models.DeviceBay fields = ['id', 'url', 'device', 'name'] +class NestedInventoryItemSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail') + device = NestedDeviceSerializer(read_only=True) + _depth = serializers.IntegerField(source='level', read_only=True) + + class Meta: + model = models.InventoryItem + fields = ['id', 'url', 'device', 'name', '_depth'] + + # # Cables # @@ -255,7 +314,7 @@ class NestedCableSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail') class Meta: - model = Cable + model = models.Cable fields = ['id', 'url', 'label'] @@ -269,8 +328,8 @@ class NestedVirtualChassisSerializer(WritableNestedSerializer): member_count = serializers.IntegerField(read_only=True) class Meta: - model = VirtualChassis - fields = ['id', 'url', 'master', 'member_count'] + model = models.VirtualChassis + fields = ['id', 'name', 'url', 'master', 'member_count'] # @@ -282,7 +341,7 @@ class NestedPowerPanelSerializer(WritableNestedSerializer): powerfeed_count = serializers.IntegerField(read_only=True) class Meta: - model = PowerPanel + model = models.PowerPanel fields = ['id', 'url', 'name', 'powerfeed_count'] @@ -290,5 +349,5 @@ class NestedPowerFeedSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail') class Meta: - model = PowerFeed - fields = ['id', 'url', 'name'] + model = models.PowerFeed + fields = ['id', 'url', 'name', 'cable'] diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 11c1f5051..0b7f2f1b2 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -1,42 +1,63 @@ +from django.conf import settings from django.contrib.contenttypes.models import ContentType from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers from rest_framework.validators import UniqueTogetherValidator -from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField from dcim.choices import * from dcim.constants import * from dcim.models import ( - Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, + Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceType, DeviceRole, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, ) +from dcim.utils import decompile_path_node from extras.api.customfields import CustomFieldModelSerializer +from extras.api.serializers import TaggedObjectSerializer from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer from ipam.models import VLAN +from netbox.api import ( + ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField, ValidatedModelSerializer, + WritableNestedSerializer, +) from tenancy.api.nested_serializers import NestedTenantSerializer from users.api.nested_serializers import NestedUserSerializer -from utilities.api import ( - ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField, ValidatedModelSerializer, - WritableNestedSerializer, get_serializer_for_model, -) +from utilities.api import get_serializer_for_model from virtualization.api.nested_serializers import NestedClusterSerializer from .nested_serializers import * +class CableTerminationSerializer(serializers.ModelSerializer): + cable_peer_type = serializers.SerializerMethodField(read_only=True) + cable_peer = serializers.SerializerMethodField(read_only=True) + + def get_cable_peer_type(self, obj): + if obj._cable_peer is not None: + return f'{obj._cable_peer._meta.app_label}.{obj._cable_peer._meta.model_name}' + return None + + @swagger_serializer_method(serializer_or_field=serializers.DictField) + def get_cable_peer(self, obj): + """ + Return the appropriate serializer for the cable termination model. + """ + if obj._cable_peer is not None: + serializer = get_serializer_for_model(obj._cable_peer, prefix='Nested') + context = {'request': self.context['request']} + return serializer(obj._cable_peer, context=context).data + return None + + 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) + connected_endpoint_reachable = serializers.SerializerMethodField(read_only=True) 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 - ) + if obj._path is not None and obj._path.destination is not None: + return f'{obj._path.destination._meta.app_label}.{obj._path.destination._meta.model_name}' return None @swagger_serializer_method(serializer_or_field=serializers.DictField) @@ -44,14 +65,17 @@ class ConnectedEndpointSerializer(ValidatedModelSerializer): """ Return the appropriate serializer for the type of connected object. """ - if getattr(obj, 'connected_endpoint', None) is None: - return None + if obj._path is not None and obj._path.destination is not None: + serializer = get_serializer_for_model(obj._path.destination, prefix='Nested') + context = {'request': self.context['request']} + return serializer(obj._path.destination, context=context).data + 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 + @swagger_serializer_method(serializer_or_field=serializers.BooleanField) + def get_connected_endpoint_reachable(self, obj): + if obj._path is not None: + return obj._path.is_active + return None # @@ -59,20 +83,22 @@ class ConnectedEndpointSerializer(ValidatedModelSerializer): # class RegionSerializer(CustomFieldModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail') parent = NestedRegionSerializer(required=False, allow_null=True) site_count = serializers.IntegerField(read_only=True) + _depth = serializers.IntegerField(source='level', read_only=True) class Meta: model = Region - fields = ['id', 'name', 'slug', 'parent', 'description', 'site_count', 'custom_fields'] + fields = ['id', 'url', 'name', 'slug', 'parent', 'description', 'site_count', 'custom_fields', '_depth'] -class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer): +class SiteSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail') status = ChoiceField(choices=SiteStatusChoices, required=False) region = NestedRegionSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) time_zone = TimeZoneField(required=False) - tags = TagListSerializerField(required=False) circuit_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True) prefix_count = serializers.IntegerField(read_only=True) @@ -83,7 +109,7 @@ class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer): class Meta: model = Site fields = [ - 'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', + 'id', 'url', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', 'device_count', 'prefix_count', 'rack_count', 'virtualmachine_count', 'vlan_count', @@ -95,24 +121,28 @@ class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer): # class RackGroupSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail') site = NestedSiteSerializer() parent = NestedRackGroupSerializer(required=False, allow_null=True) rack_count = serializers.IntegerField(read_only=True) + _depth = serializers.IntegerField(source='level', read_only=True) class Meta: model = RackGroup - fields = ['id', 'name', 'slug', 'site', 'parent', 'description', 'rack_count'] + fields = ['id', 'url', 'name', 'slug', 'site', 'parent', 'description', 'rack_count', '_depth'] class RackRoleSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail') rack_count = serializers.IntegerField(read_only=True) class Meta: model = RackRole - fields = ['id', 'name', 'slug', 'color', 'description', 'rack_count'] + fields = ['id', 'url', 'name', 'slug', 'color', 'description', 'rack_count'] -class RackSerializer(TaggitSerializer, CustomFieldModelSerializer): +class RackSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail') site = NestedSiteSerializer() group = NestedRackGroupSerializer(required=False, allow_null=True, default=None) tenant = NestedTenantSerializer(required=False, allow_null=True) @@ -121,14 +151,13 @@ class RackSerializer(TaggitSerializer, CustomFieldModelSerializer): type = ChoiceField(choices=RackTypeChoices, allow_blank=True, required=False) width = ChoiceField(choices=RackWidthChoices, required=False) outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False) - tags = TagListSerializerField(required=False) device_count = serializers.IntegerField(read_only=True) powerfeed_count = serializers.IntegerField(read_only=True) class Meta: model = Rack fields = [ - 'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'status', 'role', 'serial', + 'id', 'url', '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', 'device_count', 'powerfeed_count', ] @@ -159,16 +188,18 @@ class RackUnitSerializer(serializers.Serializer): name = serializers.CharField(read_only=True) face = ChoiceField(choices=DeviceFaceChoices, read_only=True) device = NestedDeviceSerializer(read_only=True) + occupied = serializers.BooleanField(read_only=True) -class RackReservationSerializer(ValidatedModelSerializer): +class RackReservationSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail') rack = NestedRackSerializer() user = NestedUserSerializer() tenant = NestedTenantSerializer(required=False, allow_null=True) class Meta: model = RackReservation - fields = ['id', 'rack', 'units', 'created', 'user', 'tenant', 'description'] + fields = ['id', 'url', 'rack', 'units', 'created', 'user', 'tenant', 'description', 'tags', 'custom_fields'] class RackElevationDetailFilterSerializer(serializers.Serializer): @@ -185,10 +216,10 @@ class RackElevationDetailFilterSerializer(serializers.Serializer): default=RackElevationDetailRenderChoices.RENDER_JSON ) unit_width = serializers.IntegerField( - default=RACK_ELEVATION_UNIT_WIDTH_DEFAULT + default=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH ) unit_height = serializers.IntegerField( - default=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT + default=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT ) legend_width = serializers.IntegerField( default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT @@ -212,6 +243,7 @@ class RackElevationDetailFilterSerializer(serializers.Serializer): # class ManufacturerSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail') devicetype_count = serializers.IntegerField(read_only=True) inventoryitem_count = serializers.IntegerField(read_only=True) platform_count = serializers.IntegerField(read_only=True) @@ -219,26 +251,27 @@ class ManufacturerSerializer(ValidatedModelSerializer): class Meta: model = Manufacturer fields = [ - 'id', 'name', 'slug', 'description', 'devicetype_count', 'inventoryitem_count', 'platform_count', + 'id', 'url', 'name', 'slug', 'description', 'devicetype_count', 'inventoryitem_count', 'platform_count', ] -class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer): +class DeviceTypeSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') manufacturer = NestedManufacturerSerializer() subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False) - tags = TagListSerializerField(required=False) device_count = serializers.IntegerField(read_only=True) class Meta: model = DeviceType fields = [ - 'id', 'manufacturer', 'model', 'slug', 'display_name', 'part_number', 'u_height', 'is_full_depth', + 'id', 'url', 'manufacturer', 'model', 'slug', 'display_name', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'front_image', 'rear_image', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', ] class ConsolePortTemplateSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail') device_type = NestedDeviceTypeSerializer() type = ChoiceField( choices=ConsolePortTypeChoices, @@ -248,10 +281,11 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer): class Meta: model = ConsolePortTemplate - fields = ['id', 'device_type', 'name', 'type'] + fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'description'] class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverporttemplate-detail') device_type = NestedDeviceTypeSerializer() type = ChoiceField( choices=ConsolePortTypeChoices, @@ -261,10 +295,11 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = ConsoleServerPortTemplate - fields = ['id', 'device_type', 'name', 'type'] + fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'description'] class PowerPortTemplateSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerporttemplate-detail') device_type = NestedDeviceTypeSerializer() type = ChoiceField( choices=PowerPortTypeChoices, @@ -274,10 +309,11 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = PowerPortTemplate - fields = ['id', 'device_type', 'name', 'type', 'maximum_draw', 'allocated_draw'] + fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description'] class PowerOutletTemplateSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlettemplate-detail') device_type = NestedDeviceTypeSerializer() type = ChoiceField( choices=PowerOutletTypeChoices, @@ -295,43 +331,47 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer): class Meta: model = PowerOutletTemplate - fields = ['id', 'device_type', 'name', 'type', 'power_port', 'feed_leg'] + fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description'] class InterfaceTemplateSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfacetemplate-detail') device_type = NestedDeviceTypeSerializer() type = ChoiceField(choices=InterfaceTypeChoices) class Meta: model = InterfaceTemplate - fields = ['id', 'device_type', 'name', 'type', 'mgmt_only'] + fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description'] class RearPortTemplateSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearporttemplate-detail') device_type = NestedDeviceTypeSerializer() type = ChoiceField(choices=PortTypeChoices) class Meta: model = RearPortTemplate - fields = ['id', 'device_type', 'name', 'type', 'positions'] + fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'positions', 'description'] class FrontPortTemplateSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontporttemplate-detail') device_type = NestedDeviceTypeSerializer() type = ChoiceField(choices=PortTypeChoices) rear_port = NestedRearPortTemplateSerializer() class Meta: model = FrontPortTemplate - fields = ['id', 'device_type', 'name', 'type', 'rear_port', 'rear_port_position'] + fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description'] class DeviceBayTemplateSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebaytemplate-detail') device_type = NestedDeviceTypeSerializer() class Meta: model = DeviceBayTemplate - fields = ['id', 'device_type', 'name'] + fields = ['id', 'url', 'device_type', 'name', 'label', 'description'] # @@ -339,17 +379,19 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer): # class DeviceRoleSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail') device_count = serializers.IntegerField(read_only=True) virtualmachine_count = serializers.IntegerField(read_only=True) class Meta: model = DeviceRole fields = [ - 'id', 'name', 'slug', 'color', 'vm_role', 'description', 'device_count', 'virtualmachine_count', + 'id', 'url', 'name', 'slug', 'color', 'vm_role', 'description', 'device_count', 'virtualmachine_count', ] class PlatformSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail') manufacturer = NestedManufacturerSerializer(required=False, allow_null=True) device_count = serializers.IntegerField(read_only=True) virtualmachine_count = serializers.IntegerField(read_only=True) @@ -357,12 +399,13 @@ class PlatformSerializer(ValidatedModelSerializer): class Meta: model = Platform fields = [ - 'id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'device_count', + 'id', 'url', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'device_count', 'virtualmachine_count', ] -class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer): +class DeviceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') device_type = NestedDeviceTypeSerializer() device_role = NestedDeviceRoleSerializer() tenant = NestedTenantSerializer(required=False, allow_null=True) @@ -377,15 +420,14 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer): parent_device = serializers.SerializerMethodField() cluster = NestedClusterSerializer(required=False, allow_null=True) virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True) - tags = TagListSerializerField(required=False) class Meta: model = Device fields = [ - 'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', - 'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6', - 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data', 'tags', - 'custom_fields', 'created', 'last_updated', + 'id', 'url', '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', 'local_context_data', + 'tags', 'custom_fields', 'created', 'last_updated', ] validators = [] @@ -418,10 +460,10 @@ class DeviceWithConfigContextSerializer(DeviceSerializer): 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', 'local_context_data', 'tags', - 'custom_fields', 'config_context', 'created', 'last_updated', + 'id', 'url', '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', 'local_context_data', + 'tags', 'custom_fields', 'config_context', 'created', 'last_updated', ] @swagger_serializer_method(serializer_or_field=serializers.DictField) @@ -433,7 +475,8 @@ class DeviceNAPALMSerializer(serializers.Serializer): method = serializers.DictField() -class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer): +class ConsoleServerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') device = NestedDeviceSerializer() type = ChoiceField( choices=ConsolePortTypeChoices, @@ -441,17 +484,17 @@ class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer) required=False ) cable = NestedCableSerializer(read_only=True) - tags = TagListSerializerField(required=False) class Meta: model = ConsoleServerPort fields = [ - 'id', 'device', 'name', 'type', 'description', 'connected_endpoint_type', 'connected_endpoint', - 'connection_status', 'cable', 'tags', + 'id', 'url', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'cable_peer_type', + 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', ] -class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer): +class ConsolePortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail') device = NestedDeviceSerializer() type = ChoiceField( choices=ConsolePortTypeChoices, @@ -459,17 +502,17 @@ class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer): required=False ) cable = NestedCableSerializer(read_only=True) - tags = TagListSerializerField(required=False) class Meta: model = ConsolePort fields = [ - 'id', 'device', 'name', 'type', 'description', 'connected_endpoint_type', 'connected_endpoint', - 'connection_status', 'cable', 'tags', + 'id', 'url', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'cable_peer_type', + 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', ] -class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer): +class PowerOutletSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail') device = NestedDeviceSerializer() type = ChoiceField( choices=PowerOutletTypeChoices, @@ -487,19 +530,18 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer): cable = NestedCableSerializer( read_only=True ) - tags = TagListSerializerField( - required=False - ) class Meta: model = PowerOutlet fields = [ - 'id', 'device', 'name', 'type', 'power_port', 'feed_leg', 'description', 'connected_endpoint_type', - 'connected_endpoint', 'connection_status', 'cable', 'tags', + 'id', 'url', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', + 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'connected_endpoint_reachable', 'tags', ] -class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer): +class PowerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail') device = NestedDeviceSerializer() type = ChoiceField( choices=PowerPortTypeChoices, @@ -507,17 +549,18 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer): required=False ) cable = NestedCableSerializer(read_only=True) - tags = TagListSerializerField(required=False) class Meta: model = PowerPort fields = [ - 'id', 'device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description', 'connected_endpoint_type', - 'connected_endpoint', 'connection_status', 'cable', 'tags', + 'id', 'url', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', + 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'connected_endpoint_reachable', 'tags', ] -class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer): +class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') device = NestedDeviceSerializer() type = ChoiceField(choices=InterfaceTypeChoices) lag = NestedInterfaceSerializer(required=False, allow_null=True) @@ -530,47 +573,43 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer): many=True ) cable = NestedCableSerializer(read_only=True) - tags = TagListSerializerField(required=False) count_ipaddresses = serializers.IntegerField(read_only=True) class Meta: model = Interface fields = [ - 'id', 'device', 'name', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description', - 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'mode', 'untagged_vlan', - 'tagged_vlans', 'tags', 'count_ipaddresses', + 'id', 'url', 'device', 'name', 'label', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', + 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'cable', 'cable_peer', 'cable_peer_type', + 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', '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. + # Validate many-to-many VLAN assignments device = self.instance.device if self.instance else data.get('device') - untagged_vlan = data.get('untagged_vlan') - if untagged_vlan and untagged_vlan.site not in [device.site, None]: - raise serializers.ValidationError({ - 'untagged_vlan': "VLAN {} must belong to the same site as the interface's parent device, or it must be " - "global.".format(untagged_vlan) - }) for vlan in data.get('tagged_vlans', []): if vlan.site not in [device.site, None]: raise serializers.ValidationError({ - 'tagged_vlans': "VLAN {} must belong to the same site as the interface's parent device, or it must " - "be global.".format(vlan) + 'tagged_vlans': f"VLAN {vlan} must belong to the same site as the interface's parent device, or " + f"it must be global." }) return super().validate(data) -class RearPortSerializer(TaggitSerializer, ValidatedModelSerializer): +class RearPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') device = NestedDeviceSerializer() type = ChoiceField(choices=PortTypeChoices) cable = NestedCableSerializer(read_only=True) - tags = TagListSerializerField(required=False) class Meta: model = RearPort - fields = ['id', 'device', 'name', 'type', 'positions', 'description', 'cable', 'tags'] + fields = [ + 'id', 'url', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', + 'cable_peer_type', 'tags', + ] class FrontPortRearPortSerializer(WritableNestedSerializer): @@ -581,47 +620,51 @@ class FrontPortRearPortSerializer(WritableNestedSerializer): class Meta: model = RearPort - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'name', 'label'] -class FrontPortSerializer(TaggitSerializer, ValidatedModelSerializer): +class FrontPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail') device = NestedDeviceSerializer() type = ChoiceField(choices=PortTypeChoices) rear_port = FrontPortRearPortSerializer() cable = NestedCableSerializer(read_only=True) - tags = TagListSerializerField(required=False) class Meta: model = FrontPort - fields = ['id', 'device', 'name', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'tags'] + fields = [ + 'id', 'url', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', + 'cable_peer', 'cable_peer_type', 'tags', + ] -class DeviceBaySerializer(TaggitSerializer, ValidatedModelSerializer): +class DeviceBaySerializer(TaggedObjectSerializer, ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail') device = NestedDeviceSerializer() installed_device = NestedDeviceSerializer(required=False, allow_null=True) - tags = TagListSerializerField(required=False) class Meta: model = DeviceBay - fields = ['id', 'device', 'name', 'description', 'installed_device', 'tags'] + fields = ['id', 'url', 'device', 'name', 'label', 'description', 'installed_device', 'tags'] # # Inventory items # -class InventoryItemSerializer(TaggitSerializer, ValidatedModelSerializer): +class InventoryItemSerializer(TaggedObjectSerializer, ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail') device = NestedDeviceSerializer() # Provide a default value to satisfy UniqueTogetherValidator parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None) manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None) - tags = TagListSerializerField(required=False) + _depth = serializers.IntegerField(source='level', read_only=True) class Meta: model = InventoryItem fields = [ - 'id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', - 'description', 'tags', + 'id', 'url', 'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', + 'discovered', 'description', 'tags', '_depth', ] @@ -629,7 +672,8 @@ class InventoryItemSerializer(TaggitSerializer, ValidatedModelSerializer): # Cables # -class CableSerializer(ValidatedModelSerializer): +class CableSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail') termination_a_type = ContentTypeField( queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS) ) @@ -644,8 +688,9 @@ class CableSerializer(ValidatedModelSerializer): class Meta: 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', + 'id', 'url', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type', + 'termination_b_id', 'termination_b', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags', + 'custom_fields', ] def _get_termination(self, obj, side): @@ -685,6 +730,49 @@ class TracedCableSerializer(serializers.ModelSerializer): ] +class CablePathSerializer(serializers.ModelSerializer): + origin_type = ContentTypeField(read_only=True) + origin = serializers.SerializerMethodField(read_only=True) + destination_type = ContentTypeField(read_only=True) + destination = serializers.SerializerMethodField(read_only=True) + path = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = CablePath + fields = [ + 'id', 'origin_type', 'origin', 'destination_type', 'destination', 'path', 'is_active', 'is_split', + ] + + @swagger_serializer_method(serializer_or_field=serializers.DictField) + def get_origin(self, obj): + """ + Return the appropriate serializer for the origin. + """ + serializer = get_serializer_for_model(obj.origin, prefix='Nested') + context = {'request': self.context['request']} + return serializer(obj.origin, context=context).data + + @swagger_serializer_method(serializer_or_field=serializers.DictField) + def get_destination(self, obj): + """ + Return the appropriate serializer for the destination, if any. + """ + if obj.destination_id is not None: + serializer = get_serializer_for_model(obj.destination, prefix='Nested') + context = {'request': self.context['request']} + return serializer(obj.destination, context=context).data + return None + + @swagger_serializer_method(serializer_or_field=serializers.ListField) + def get_path(self, obj): + ret = [] + for node in obj.get_path(): + serializer = get_serializer_for_model(node, prefix='Nested') + context = {'request': self.context['request']} + ret.append(serializer(node, context=context).data) + return ret + + # # Interface connections # @@ -692,37 +780,44 @@ class TracedCableSerializer(serializers.ModelSerializer): class InterfaceConnectionSerializer(ValidatedModelSerializer): interface_a = serializers.SerializerMethodField() interface_b = NestedInterfaceSerializer(source='connected_endpoint') - connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, required=False) + connected_endpoint_reachable = serializers.SerializerMethodField(read_only=True) class Meta: model = Interface - fields = ['interface_a', 'interface_b', 'connection_status'] + fields = ['interface_a', 'interface_b', 'connected_endpoint_reachable'] @swagger_serializer_method(serializer_or_field=NestedInterfaceSerializer) def get_interface_a(self, obj): context = {'request': self.context['request']} return NestedInterfaceSerializer(instance=obj, context=context).data + @swagger_serializer_method(serializer_or_field=serializers.BooleanField) + def get_connected_endpoint_reachable(self, obj): + if obj._path is not None: + return obj._path.is_active + return None + # # Virtual chassis # -class VirtualChassisSerializer(TaggitSerializer, ValidatedModelSerializer): - master = NestedDeviceSerializer() - tags = TagListSerializerField(required=False) +class VirtualChassisSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') + master = NestedDeviceSerializer(required=False) member_count = serializers.IntegerField(read_only=True) class Meta: model = VirtualChassis - fields = ['id', 'master', 'domain', 'tags', 'member_count'] + fields = ['id', 'url', 'name', 'domain', 'master', 'tags', 'custom_fields', 'member_count'] # # Power panels # -class PowerPanelSerializer(ValidatedModelSerializer): +class PowerPanelSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail') site = NestedSiteSerializer() rack_group = NestedRackGroupSerializer( required=False, @@ -733,10 +828,16 @@ class PowerPanelSerializer(ValidatedModelSerializer): class Meta: model = PowerPanel - fields = ['id', 'site', 'rack_group', 'name', 'powerfeed_count'] + fields = ['id', 'url', 'site', 'rack_group', 'name', 'tags', 'custom_fields', 'powerfeed_count'] -class PowerFeedSerializer(TaggitSerializer, CustomFieldModelSerializer): +class PowerFeedSerializer( + TaggedObjectSerializer, + CableTerminationSerializer, + ConnectedEndpointSerializer, + CustomFieldModelSerializer +): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail') power_panel = NestedPowerPanelSerializer() rack = NestedRackSerializer( required=False, @@ -759,13 +860,13 @@ class PowerFeedSerializer(TaggitSerializer, CustomFieldModelSerializer): choices=PowerFeedPhaseChoices, default=PowerFeedPhaseChoices.PHASE_SINGLE ) - tags = TagListSerializerField( - required=False - ) + cable = NestedCableSerializer(read_only=True) class Meta: model = PowerFeed fields = [ - 'id', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', - 'max_utilization', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', + 'max_utilization', 'comments', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', + 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', + 'last_updated', ] diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index f989d817c..689cb7aa1 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -1,18 +1,9 @@ -from rest_framework import routers - +from netbox.api import OrderedDefaultRouter from . import views -class DCIMRootView(routers.APIRootView): - """ - DCIM API root view - """ - def get_view_name(self): - return 'DCIM' - - -router = routers.DefaultRouter() -router.APIRootView = DCIMRootView +router = OrderedDefaultRouter() +router.APIRootView = views.DCIMRootView # Sites router.register('regions', views.RegionViewSet) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 10f31b1eb..379873ade 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -1,7 +1,8 @@ +import socket from collections import OrderedDict from django.conf import settings -from django.db.models import Count, F +from django.db.models import F from django.http import HttpResponseForbidden, HttpResponse from django.shortcuts import get_object_or_404 from drf_yasg import openapi @@ -10,45 +11,57 @@ from drf_yasg.utils import swagger_auto_schema from rest_framework.decorators import action from rest_framework.mixins import ListModelMixin from rest_framework.response import Response +from rest_framework.routers import APIRootView from rest_framework.viewsets import GenericViewSet, ViewSet from circuits.models import Circuit from dcim import filters from dcim.models import ( - Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, + Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, 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 +from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet from ipam.models import Prefix, VLAN -from utilities.api import ( - get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, ModelViewSet, ServiceUnavailable, -) -from utilities.utils import get_subquery +from netbox.api.views import ModelViewSet +from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired +from netbox.api.exceptions import ServiceUnavailable +from netbox.api.metadata import ContentTypeMetadata +from utilities.api import get_serializer_for_model +from utilities.utils import count_related from virtualization.models import VirtualMachine from . import serializers from .exceptions import MissingFilterException +class DCIMRootView(APIRootView): + """ + DCIM API root view + """ + def get_view_name(self): + return 'DCIM' + + # Mixins -class CableTraceMixin(object): +class PathEndpointMixin(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) + obj = get_object_or_404(self.queryset, pk=pk) # Initialize the path array path = [] - for near_end, cable, far_end in obj.trace()[0]: + for near_end, cable, far_end in obj.trace(): + if near_end is None: + # Split paths + break # Serialize each object serializer_a = get_serializer_for_model(near_end, prefix='Nested') @@ -68,13 +81,31 @@ class CableTraceMixin(object): return Response(path) +class PassThroughPortMixin(object): + + @action(detail=True, url_path='paths') + def paths(self, request, pk): + """ + Return all CablePaths which traverse a given pass-through port. + """ + obj = get_object_or_404(self.queryset, pk=pk) + cablepaths = CablePath.objects.filter(path__contains=obj).prefetch_related('origin', 'destination') + serializer = serializers.CablePathSerializer(cablepaths, context={'request': request}, many=True) + + return Response(serializer.data) + + # # Regions # class RegionViewSet(CustomFieldModelViewSet): - queryset = Region.objects.annotate( - site_count=Count('sites') + queryset = Region.objects.add_related_count( + Region.objects.all(), + Site, + 'region', + 'site_count', + cumulative=True ) serializer_class = serializers.RegionSerializer filterset_class = filters.RegionFilterSet @@ -88,35 +119,29 @@ class SiteViewSet(CustomFieldModelViewSet): queryset = Site.objects.prefetch_related( 'region', 'tenant', 'tags' ).annotate( - device_count=get_subquery(Device, 'site'), - rack_count=get_subquery(Rack, 'site'), - prefix_count=get_subquery(Prefix, 'site'), - vlan_count=get_subquery(VLAN, 'site'), - circuit_count=get_subquery(Circuit, 'terminations__site'), - virtualmachine_count=get_subquery(VirtualMachine, 'cluster__site'), + device_count=count_related(Device, 'site'), + rack_count=count_related(Rack, 'site'), + prefix_count=count_related(Prefix, 'site'), + vlan_count=count_related(VLAN, 'site'), + circuit_count=count_related(Circuit, 'terminations__site'), + virtualmachine_count=count_related(VirtualMachine, 'cluster__site') ) serializer_class = serializers.SiteSerializer filterset_class = filters.SiteFilterSet - @action(detail=True) - def graphs(self, request, pk): - """ - A convenience method for rendering graphs for a particular site. - """ - site = get_object_or_404(Site, pk=pk) - queryset = Graph.objects.filter(type__model='site') - serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': site}) - return Response(serializer.data) - # # Rack groups # class RackGroupViewSet(ModelViewSet): - queryset = RackGroup.objects.prefetch_related('site').annotate( - rack_count=Count('racks') - ) + queryset = RackGroup.objects.add_related_count( + RackGroup.objects.all(), + Rack, + 'group', + 'rack_count', + cumulative=True + ).prefetch_related('site') serializer_class = serializers.RackGroupSerializer filterset_class = filters.RackGroupFilterSet @@ -127,7 +152,7 @@ class RackGroupViewSet(ModelViewSet): class RackRoleViewSet(ModelViewSet): queryset = RackRole.objects.annotate( - rack_count=Count('racks') + rack_count=count_related(Rack, 'role') ) serializer_class = serializers.RackRoleSerializer filterset_class = filters.RackRoleFilterSet @@ -141,8 +166,8 @@ class RackViewSet(CustomFieldModelViewSet): queryset = Rack.objects.prefetch_related( 'site', 'group__site', 'role', 'tenant', 'tags' ).annotate( - device_count=get_subquery(Device, 'rack'), - powerfeed_count=get_subquery(PowerFeed, 'rack') + device_count=count_related(Device, 'rack'), + powerfeed_count=count_related(PowerFeed, 'rack') ) serializer_class = serializers.RackSerializer filterset_class = filters.RackFilterSet @@ -156,7 +181,7 @@ class RackViewSet(CustomFieldModelViewSet): """ Rack elevation representing the list of rack units. Also supports rendering the elevation as an SVG. """ - rack = get_object_or_404(Rack, pk=pk) + rack = get_object_or_404(self.queryset, pk=pk) serializer = serializers.RackElevationDetailFilterSerializer(data=request.GET) if not serializer.is_valid(): return Response(serializer.errors, 400) @@ -166,6 +191,7 @@ class RackViewSet(CustomFieldModelViewSet): # Render and return the elevation as an SVG drawing with the correct content type drawing = rack.get_elevation_svg( face=data['face'], + user=request.user, unit_width=data['unit_width'], unit_height=data['unit_height'], legend_width=data['legend_width'], @@ -178,6 +204,7 @@ class RackViewSet(CustomFieldModelViewSet): # Return a JSON representation of the rack units in the elevation elevation = rack.get_rack_units( face=data['face'], + user=request.user, exclude=data['exclude'], expand_devices=data['expand_devices'] ) @@ -213,9 +240,9 @@ class RackReservationViewSet(ModelViewSet): class ManufacturerViewSet(ModelViewSet): queryset = Manufacturer.objects.annotate( - devicetype_count=get_subquery(DeviceType, 'manufacturer'), - inventoryitem_count=get_subquery(InventoryItem, 'manufacturer'), - platform_count=get_subquery(Platform, 'manufacturer') + devicetype_count=count_related(DeviceType, 'manufacturer'), + inventoryitem_count=count_related(InventoryItem, 'manufacturer'), + platform_count=count_related(Platform, 'manufacturer') ) serializer_class = serializers.ManufacturerSerializer filterset_class = filters.ManufacturerFilterSet @@ -226,11 +253,12 @@ class ManufacturerViewSet(ModelViewSet): # class DeviceTypeViewSet(CustomFieldModelViewSet): - queryset = DeviceType.objects.prefetch_related('manufacturer').prefetch_related('tags').annotate( - device_count=Count('instances') + queryset = DeviceType.objects.prefetch_related('manufacturer', 'tags').annotate( + device_count=count_related(Device, 'device_type') ) serializer_class = serializers.DeviceTypeSerializer filterset_class = filters.DeviceTypeFilterSet + brief_prefetch_fields = ['manufacturer'] # @@ -291,8 +319,8 @@ class DeviceBayTemplateViewSet(ModelViewSet): class DeviceRoleViewSet(ModelViewSet): queryset = DeviceRole.objects.annotate( - device_count=get_subquery(Device, 'device_role'), - virtualmachine_count=get_subquery(VirtualMachine, 'role') + device_count=count_related(Device, 'device_role'), + virtualmachine_count=count_related(VirtualMachine, 'role') ) serializer_class = serializers.DeviceRoleSerializer filterset_class = filters.DeviceRoleFilterSet @@ -304,8 +332,8 @@ class DeviceRoleViewSet(ModelViewSet): class PlatformViewSet(ModelViewSet): queryset = Platform.objects.annotate( - device_count=get_subquery(Device, 'platform'), - virtualmachine_count=get_subquery(VirtualMachine, 'platform') + device_count=count_related(Device, 'platform'), + virtualmachine_count=count_related(VirtualMachine, 'platform') ) serializer_class = serializers.PlatformSerializer filterset_class = filters.PlatformFilterSet @@ -315,7 +343,7 @@ class PlatformViewSet(ModelViewSet): # Devices # -class DeviceViewSet(CustomFieldModelViewSet): +class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet): queryset = Device.objects.prefetch_related( 'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay', 'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags', @@ -342,17 +370,6 @@ class DeviceViewSet(CustomFieldModelViewSet): return serializers.DeviceWithConfigContextSerializer - @action(detail=True) - def graphs(self, request, pk): - """ - A convenience method for rendering graphs for a particular Device. - """ - device = get_object_or_404(Device, pk=pk) - queryset = Graph.objects.filter(type__model='device') - serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': device}) - - return Response(serializer.data) - @swagger_auto_schema( manual_parameters=[ Parameter( @@ -369,22 +386,40 @@ class DeviceViewSet(CustomFieldModelViewSet): """ Execute a NAPALM method on a Device """ - device = get_object_or_404(Device, pk=pk) + device = get_object_or_404(self.queryset, pk=pk) if not device.primary_ip: raise ServiceUnavailable("This device does not have a primary IP address configured.") if device.platform is None: raise ServiceUnavailable("No platform is configured for this device.") if not device.platform.napalm_driver: - raise ServiceUnavailable("No NAPALM driver is configured for this device's platform ().".format( - device.platform - )) + raise ServiceUnavailable(f"No NAPALM driver is configured for this device's platform: {device.platform}.") + + # Check for primary IP address from NetBox object + if device.primary_ip: + host = str(device.primary_ip.address.ip) + else: + # Raise exception for no IP address and no Name if device.name does not exist + if not device.name: + raise ServiceUnavailable( + "This device does not have a primary IP address or device name to lookup configured." + ) + try: + # Attempt to complete a DNS name resolution if no primary_ip is set + host = socket.gethostbyname(device.name) + except socket.gaierror: + # Name lookup failure + raise ServiceUnavailable( + f"Name lookup failure, unable to resolve IP address for {device.name}. Please set Primary IP or " + f"setup name resolution.") # Check that NAPALM is installed try: import napalm from napalm.base.exceptions import ModuleImportError - except ImportError: - raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.") + except ModuleNotFoundError as e: + if getattr(e, 'name') == 'napalm': + raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.") + raise e # Validate the configured driver try: @@ -395,13 +430,11 @@ class DeviceViewSet(CustomFieldModelViewSet): )) # Verify user permission - if not request.user.has_perm('dcim.napalm_read'): + if not request.user.has_perm('dcim.napalm_read_device'): return HttpResponseForbidden() - # Connect to the device napalm_methods = request.GET.getlist('method') response = OrderedDict([(m, None) for m in napalm_methods]) - ip_address = str(device.primary_ip.address.ip) username = settings.NAPALM_USERNAME password = settings.NAPALM_PASSWORD optional_args = settings.NAPALM_ARGS.copy() @@ -421,8 +454,9 @@ class DeviceViewSet(CustomFieldModelViewSet): elif key: optional_args[key.lower()] = request.headers[header] + # Connect to the device d = driver( - hostname=ip_address, + hostname=host, username=username, password=password, timeout=settings.NAPALM_TIMEOUT, @@ -431,7 +465,7 @@ class DeviceViewSet(CustomFieldModelViewSet): try: d.open() except Exception as e: - raise ServiceUnavailable("Error connecting to the device at {}: {}".format(ip_address, e)) + raise ServiceUnavailable("Error connecting to the device at {}: {}".format(host, e)) # Validate and execute each specified NAPALM method for method in napalm_methods: @@ -456,74 +490,71 @@ class DeviceViewSet(CustomFieldModelViewSet): # Device components # -class ConsolePortViewSet(CableTraceMixin, ModelViewSet): - queryset = ConsolePort.objects.prefetch_related('device', 'connected_endpoint__device', 'cable', 'tags') +class ConsolePortViewSet(PathEndpointMixin, ModelViewSet): + queryset = ConsolePort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags') serializer_class = serializers.ConsolePortSerializer filterset_class = filters.ConsolePortFilterSet + brief_prefetch_fields = ['device'] -class ConsoleServerPortViewSet(CableTraceMixin, ModelViewSet): - queryset = ConsoleServerPort.objects.prefetch_related('device', 'connected_endpoint__device', 'cable', 'tags') +class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet): + queryset = ConsoleServerPort.objects.prefetch_related( + 'device', '_path__destination', 'cable', '_cable_peer', 'tags' + ) serializer_class = serializers.ConsoleServerPortSerializer filterset_class = filters.ConsoleServerPortFilterSet + brief_prefetch_fields = ['device'] -class PowerPortViewSet(CableTraceMixin, ModelViewSet): - queryset = PowerPort.objects.prefetch_related( - 'device', '_connected_poweroutlet__device', '_connected_powerfeed', 'cable', 'tags' - ) +class PowerPortViewSet(PathEndpointMixin, ModelViewSet): + queryset = PowerPort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags') serializer_class = serializers.PowerPortSerializer filterset_class = filters.PowerPortFilterSet + brief_prefetch_fields = ['device'] -class PowerOutletViewSet(CableTraceMixin, ModelViewSet): - queryset = PowerOutlet.objects.prefetch_related('device', 'connected_endpoint__device', 'cable', 'tags') +class PowerOutletViewSet(PathEndpointMixin, ModelViewSet): + queryset = PowerOutlet.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags') serializer_class = serializers.PowerOutletSerializer filterset_class = filters.PowerOutletFilterSet + brief_prefetch_fields = ['device'] -class InterfaceViewSet(CableTraceMixin, ModelViewSet): +class InterfaceViewSet(PathEndpointMixin, ModelViewSet): queryset = Interface.objects.prefetch_related( - 'device', '_connected_interface', '_connected_circuittermination', 'cable', 'ip_addresses', 'tags' - ).filter( - device__isnull=False + 'device', '_path__destination', 'cable', '_cable_peer', 'ip_addresses', 'tags' ) serializer_class = serializers.InterfaceSerializer filterset_class = filters.InterfaceFilterSet - - @action(detail=True) - def graphs(self, request, pk): - """ - A convenience method for rendering graphs for a particular interface. - """ - interface = get_object_or_404(Interface, pk=pk) - queryset = Graph.objects.filter(type__model='interface') - serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': interface}) - return Response(serializer.data) + brief_prefetch_fields = ['device'] -class FrontPortViewSet(ModelViewSet): +class FrontPortViewSet(PassThroughPortMixin, ModelViewSet): queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags') serializer_class = serializers.FrontPortSerializer filterset_class = filters.FrontPortFilterSet + brief_prefetch_fields = ['device'] -class RearPortViewSet(ModelViewSet): +class RearPortViewSet(PassThroughPortMixin, ModelViewSet): queryset = RearPort.objects.prefetch_related('device__device_type__manufacturer', 'cable', 'tags') serializer_class = serializers.RearPortSerializer filterset_class = filters.RearPortFilterSet + brief_prefetch_fields = ['device'] class DeviceBayViewSet(ModelViewSet): queryset = DeviceBay.objects.prefetch_related('installed_device').prefetch_related('tags') serializer_class = serializers.DeviceBaySerializer filterset_class = filters.DeviceBayFilterSet + brief_prefetch_fields = ['device'] class InventoryItemViewSet(ModelViewSet): queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer').prefetch_related('tags') serializer_class = serializers.InventoryItemSerializer filterset_class = filters.InventoryItemFilterSet + brief_prefetch_fields = ['device'] # @@ -531,32 +562,26 @@ class InventoryItemViewSet(ModelViewSet): # class ConsoleConnectionViewSet(ListModelMixin, GenericViewSet): - queryset = ConsolePort.objects.prefetch_related( - 'device', 'connected_endpoint__device' - ).filter( - connected_endpoint__isnull=False + queryset = ConsolePort.objects.prefetch_related('device', '_path').filter( + _path__destination_id__isnull=False ) serializer_class = serializers.ConsolePortSerializer filterset_class = filters.ConsoleConnectionFilterSet class PowerConnectionViewSet(ListModelMixin, GenericViewSet): - queryset = PowerPort.objects.prefetch_related( - 'device', 'connected_endpoint__device' - ).filter( - _connected_poweroutlet__isnull=False + queryset = PowerPort.objects.prefetch_related('device', '_path').filter( + _path__destination_id__isnull=False ) serializer_class = serializers.PowerPortSerializer filterset_class = filters.PowerConnectionFilterSet class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet): - queryset = Interface.objects.prefetch_related( - 'device', '_connected_interface__device' - ).filter( + queryset = Interface.objects.prefetch_related('device', '_path').filter( # Avoid duplicate connections by only selecting the lower PK in a connected pair - _connected_interface__isnull=False, - pk__lt=F('_connected_interface') + _path__destination_id__isnull=False, + pk__lt=F('_path__destination_id') ) serializer_class = serializers.InterfaceConnectionSerializer filterset_class = filters.InterfaceConnectionFilterSet @@ -567,6 +592,7 @@ class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet): # class CableViewSet(ModelViewSet): + metadata_class = ContentTypeMetadata queryset = Cable.objects.prefetch_related( 'termination_a', 'termination_b' ) @@ -580,10 +606,11 @@ class CableViewSet(ModelViewSet): class VirtualChassisViewSet(ModelViewSet): queryset = VirtualChassis.objects.prefetch_related('tags').annotate( - member_count=Count('members') + member_count=count_related(Device, 'virtual_chassis') ) serializer_class = serializers.VirtualChassisSerializer filterset_class = filters.VirtualChassisFilterSet + brief_prefetch_fields = ['master'] # @@ -594,7 +621,7 @@ class PowerPanelViewSet(ModelViewSet): queryset = PowerPanel.objects.prefetch_related( 'site', 'rack_group' ).annotate( - powerfeed_count=Count('powerfeeds') + powerfeed_count=count_related(PowerFeed, 'power_panel') ) serializer_class = serializers.PowerPanelSerializer filterset_class = filters.PowerPanelFilterSet @@ -604,8 +631,10 @@ class PowerPanelViewSet(ModelViewSet): # Power feeds # -class PowerFeedViewSet(CustomFieldModelViewSet): - queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack', 'tags') +class PowerFeedViewSet(PathEndpointMixin, CustomFieldModelViewSet): + queryset = PowerFeed.objects.prefetch_related( + 'power_panel', 'rack', '_path__destination', 'cable', '_cable_peer', 'tags' + ) serializer_class = serializers.PowerFeedSerializer filterset_class = filters.PowerFeedFilterSet @@ -655,8 +684,12 @@ class ConnectedDeviceViewSet(ViewSet): 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 + peer_interface = get_object_or_404( + Interface.objects.all(), + device__name=peer_device_name, + name=peer_interface_name + ) + local_interface = peer_interface.connected_endpoint if local_interface is None: return Response() diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index e79222449..436fb0a04 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -7,26 +7,30 @@ from utilities.choices import ChoiceSet class SiteStatusChoices(ChoiceSet): - STATUS_ACTIVE = 'active' STATUS_PLANNED = 'planned' + STATUS_STAGING = 'staging' + STATUS_ACTIVE = 'active' + STATUS_DECOMMISSIONING = 'decommissioning' STATUS_RETIRED = 'retired' STATUS_CONSTRUCTION = 'construction' STATUS_COMMISSIONING = 'commissioning' CHOICES = ( - (STATUS_ACTIVE, 'Active'), (STATUS_PLANNED, 'Planned'), + (STATUS_STAGING, 'Staging'), + (STATUS_ACTIVE, 'Active'), + (STATUS_DECOMMISSIONING, 'Decommissioning'), (STATUS_RETIRED, 'Retired'), (STATUS_CONSTRUCTION, 'Construction'), (STATUS_COMMISSIONING, 'Commissioning'), ) - LEGACY_MAP = { - STATUS_ACTIVE: 1, - STATUS_PLANNED: 2, - STATUS_RETIRED: 4, - STATUS_CONSTRUCTION: 90, - STATUS_COMMISSIONING: 91, + CSS_CLASSES = { + STATUS_PLANNED: 'info', + STATUS_STAGING: 'primary', + STATUS_ACTIVE: 'success', + STATUS_DECOMMISSIONING: 'warning', + STATUS_RETIRED: 'danger', } @@ -50,14 +54,6 @@ class RackTypeChoices(ChoiceSet): (TYPE_WALLCABINET, 'Wall-mounted cabinet'), ) - LEGACY_MAP = { - TYPE_2POST: 100, - TYPE_4POST: 200, - TYPE_CABINET: 300, - TYPE_WALLFRAME: 1000, - TYPE_WALLCABINET: 1100, - } - class RackWidthChoices(ChoiceSet): @@ -90,12 +86,12 @@ class RackStatusChoices(ChoiceSet): (STATUS_DEPRECATED, 'Deprecated'), ) - LEGACY_MAP = { - STATUS_RESERVED: 0, - STATUS_AVAILABLE: 1, - STATUS_PLANNED: 2, - STATUS_ACTIVE: 3, - STATUS_DEPRECATED: 4, + CSS_CLASSES = { + STATUS_RESERVED: 'warning', + STATUS_AVAILABLE: 'success', + STATUS_PLANNED: 'info', + STATUS_ACTIVE: 'primary', + STATUS_DEPRECATED: 'danger', } @@ -109,11 +105,6 @@ class RackDimensionUnitChoices(ChoiceSet): (UNIT_INCH, 'Inches'), ) - LEGACY_MAP = { - UNIT_MILLIMETER: 1000, - UNIT_INCH: 2000, - } - class RackElevationDetailRenderChoices(ChoiceSet): @@ -140,11 +131,6 @@ class SubdeviceRoleChoices(ChoiceSet): (ROLE_CHILD, 'Child'), ) - LEGACY_MAP = { - ROLE_PARENT: True, - ROLE_CHILD: False, - } - # # Devices @@ -160,11 +146,6 @@ class DeviceFaceChoices(ChoiceSet): (FACE_REAR, 'Rear'), ) - LEGACY_MAP = { - FACE_FRONT: 0, - FACE_REAR: 1, - } - class DeviceStatusChoices(ChoiceSet): @@ -186,14 +167,14 @@ class DeviceStatusChoices(ChoiceSet): (STATUS_DECOMMISSIONING, 'Decommissioning'), ) - LEGACY_MAP = { - STATUS_OFFLINE: 0, - STATUS_ACTIVE: 1, - STATUS_PLANNED: 2, - STATUS_STAGED: 3, - STATUS_FAILED: 4, - STATUS_INVENTORY: 5, - STATUS_DECOMMISSIONING: 6, + CSS_CLASSES = { + STATUS_OFFLINE: 'warning', + STATUS_ACTIVE: 'success', + STATUS_PLANNED: 'info', + STATUS_STAGED: 'primary', + STATUS_FAILED: 'danger', + STATUS_INVENTORY: 'default', + STATUS_DECOMMISSIONING: 'warning', } @@ -266,6 +247,7 @@ class PowerPortTypeChoices(ChoiceSet): TYPE_IEC_3PNE6H = 'iec-60309-3p-n-e-6h' TYPE_IEC_3PNE9H = 'iec-60309-3p-n-e-9h' # NEMA non-locking + TYPE_NEMA_115P = 'nema-1-15p' TYPE_NEMA_515P = 'nema-5-15p' TYPE_NEMA_520P = 'nema-5-20p' TYPE_NEMA_530P = 'nema-5-30p' @@ -274,16 +256,36 @@ class PowerPortTypeChoices(ChoiceSet): TYPE_NEMA_620P = 'nema-6-20p' TYPE_NEMA_630P = 'nema-6-30p' TYPE_NEMA_650P = 'nema-6-50p' + TYPE_NEMA_1030P = 'nema-10-30p' + TYPE_NEMA_1050P = 'nema-10-50p' + TYPE_NEMA_1420P = 'nema-14-20p' + TYPE_NEMA_1430P = 'nema-14-30p' + TYPE_NEMA_1450P = 'nema-14-50p' + TYPE_NEMA_1460P = 'nema-14-60p' + TYPE_NEMA_1515P = 'nema-15-15p' + TYPE_NEMA_1520P = 'nema-15-20p' + TYPE_NEMA_1530P = 'nema-15-30p' + TYPE_NEMA_1550P = 'nema-15-50p' + TYPE_NEMA_1560P = 'nema-15-60p' # NEMA locking + TYPE_NEMA_L115P = 'nema-l1-15p' TYPE_NEMA_L515P = 'nema-l5-15p' TYPE_NEMA_L520P = 'nema-l5-20p' TYPE_NEMA_L530P = 'nema-l5-30p' - TYPE_NEMA_L615P = 'nema-l5-50p' + TYPE_NEMA_L550P = 'nema-l5-50p' + TYPE_NEMA_L615P = 'nema-l6-15p' TYPE_NEMA_L620P = 'nema-l6-20p' TYPE_NEMA_L630P = 'nema-l6-30p' TYPE_NEMA_L650P = 'nema-l6-50p' + TYPE_NEMA_L1030P = 'nema-l10-30p' TYPE_NEMA_L1420P = 'nema-l14-20p' TYPE_NEMA_L1430P = 'nema-l14-30p' + TYPE_NEMA_L1450P = 'nema-l14-50p' + TYPE_NEMA_L1460P = 'nema-l14-60p' + TYPE_NEMA_L1520P = 'nema-l15-20p' + TYPE_NEMA_L1530P = 'nema-l15-30p' + TYPE_NEMA_L1550P = 'nema-l15-50p' + TYPE_NEMA_L1560P = 'nema-l15-60p' TYPE_NEMA_L2120P = 'nema-l21-20p' TYPE_NEMA_L2130P = 'nema-l21-30p' # California style @@ -306,6 +308,16 @@ class PowerPortTypeChoices(ChoiceSet): TYPE_ITA_M = 'ita-m' TYPE_ITA_N = 'ita-n' TYPE_ITA_O = 'ita-o' + # USB + TYPE_USB_A = 'usb-a' + TYPE_USB_B = 'usb-b' + TYPE_USB_C = 'usb-c' + TYPE_USB_MINI_A = 'usb-mini-a' + TYPE_USB_MINI_B = 'usb-mini-b' + TYPE_USB_MICRO_A = 'usb-micro-a' + TYPE_USB_MICRO_B = 'usb-micro-b' + TYPE_USB_3_B = 'usb-3-b' + TYPE_USB_3_MICROB = 'usb-3-micro-b' CHOICES = ( ('IEC 60320', ( @@ -330,6 +342,7 @@ class PowerPortTypeChoices(ChoiceSet): (TYPE_IEC_3PNE9H, '3P+N+E 9H'), )), ('NEMA (Non-locking)', ( + (TYPE_NEMA_115P, 'NEMA 1-15P'), (TYPE_NEMA_515P, 'NEMA 5-15P'), (TYPE_NEMA_520P, 'NEMA 5-20P'), (TYPE_NEMA_530P, 'NEMA 5-30P'), @@ -338,17 +351,37 @@ class PowerPortTypeChoices(ChoiceSet): (TYPE_NEMA_620P, 'NEMA 6-20P'), (TYPE_NEMA_630P, 'NEMA 6-30P'), (TYPE_NEMA_650P, 'NEMA 6-50P'), + (TYPE_NEMA_1030P, 'NEMA 10-30P'), + (TYPE_NEMA_1050P, 'NEMA 10-50P'), + (TYPE_NEMA_1420P, 'NEMA 14-20P'), + (TYPE_NEMA_1430P, 'NEMA 14-30P'), + (TYPE_NEMA_1450P, 'NEMA 14-50P'), + (TYPE_NEMA_1460P, 'NEMA 14-60P'), + (TYPE_NEMA_1515P, 'NEMA 15-15P'), + (TYPE_NEMA_1520P, 'NEMA 15-20P'), + (TYPE_NEMA_1530P, 'NEMA 15-30P'), + (TYPE_NEMA_1550P, 'NEMA 15-50P'), + (TYPE_NEMA_1560P, 'NEMA 15-60P'), )), ('NEMA (Locking)', ( + (TYPE_NEMA_L115P, 'NEMA L1-15P'), (TYPE_NEMA_L515P, 'NEMA L5-15P'), (TYPE_NEMA_L520P, 'NEMA L5-20P'), (TYPE_NEMA_L530P, 'NEMA L5-30P'), + (TYPE_NEMA_L550P, 'NEMA L5-50P'), (TYPE_NEMA_L615P, 'NEMA L6-15P'), (TYPE_NEMA_L620P, 'NEMA L6-20P'), (TYPE_NEMA_L630P, 'NEMA L6-30P'), (TYPE_NEMA_L650P, 'NEMA L6-50P'), + (TYPE_NEMA_L1030P, 'NEMA L10-30P'), (TYPE_NEMA_L1420P, 'NEMA L14-20P'), (TYPE_NEMA_L1430P, 'NEMA L14-30P'), + (TYPE_NEMA_L1450P, 'NEMA L14-50P'), + (TYPE_NEMA_L1460P, 'NEMA L14-60P'), + (TYPE_NEMA_L1520P, 'NEMA L15-20P'), + (TYPE_NEMA_L1530P, 'NEMA L15-30P'), + (TYPE_NEMA_L1550P, 'NEMA L15-50P'), + (TYPE_NEMA_L1560P, 'NEMA L15-60P'), (TYPE_NEMA_L2120P, 'NEMA L21-20P'), (TYPE_NEMA_L2130P, 'NEMA L21-30P'), )), @@ -374,6 +407,17 @@ class PowerPortTypeChoices(ChoiceSet): (TYPE_ITA_N, 'ITA Type N'), (TYPE_ITA_O, 'ITA Type O'), )), + ('USB', ( + (TYPE_USB_A, 'USB Type A'), + (TYPE_USB_B, 'USB Type B'), + (TYPE_USB_C, 'USB Type C'), + (TYPE_USB_MINI_A, 'USB Mini A'), + (TYPE_USB_MINI_B, 'USB Mini B'), + (TYPE_USB_MICRO_A, 'USB Micro A'), + (TYPE_USB_MICRO_B, 'USB Micro B'), + (TYPE_USB_3_B, 'USB 3.0 Type B'), + (TYPE_USB_3_MICROB, 'USB 3.0 Micro B'), + )), ) @@ -403,6 +447,7 @@ class PowerOutletTypeChoices(ChoiceSet): TYPE_IEC_3PNE6H = 'iec-60309-3p-n-e-6h' TYPE_IEC_3PNE9H = 'iec-60309-3p-n-e-9h' # NEMA non-locking + TYPE_NEMA_115R = 'nema-1-15r' TYPE_NEMA_515R = 'nema-5-15r' TYPE_NEMA_520R = 'nema-5-20r' TYPE_NEMA_530R = 'nema-5-30r' @@ -411,16 +456,36 @@ class PowerOutletTypeChoices(ChoiceSet): TYPE_NEMA_620R = 'nema-6-20r' TYPE_NEMA_630R = 'nema-6-30r' TYPE_NEMA_650R = 'nema-6-50r' + TYPE_NEMA_1030R = 'nema-10-30r' + TYPE_NEMA_1050R = 'nema-10-50r' + TYPE_NEMA_1420R = 'nema-14-20r' + TYPE_NEMA_1430R = 'nema-14-30r' + TYPE_NEMA_1450R = 'nema-14-50r' + TYPE_NEMA_1460R = 'nema-14-60r' + TYPE_NEMA_1515R = 'nema-15-15r' + TYPE_NEMA_1520R = 'nema-15-20r' + TYPE_NEMA_1530R = 'nema-15-30r' + TYPE_NEMA_1550R = 'nema-15-50r' + TYPE_NEMA_1560R = 'nema-15-60r' # NEMA locking + TYPE_NEMA_L115R = 'nema-l1-15r' TYPE_NEMA_L515R = 'nema-l5-15r' TYPE_NEMA_L520R = 'nema-l5-20r' TYPE_NEMA_L530R = 'nema-l5-30r' - TYPE_NEMA_L615R = 'nema-l5-50r' + TYPE_NEMA_L550R = 'nema-l5-50r' + TYPE_NEMA_L615R = 'nema-l6-15r' TYPE_NEMA_L620R = 'nema-l6-20r' TYPE_NEMA_L630R = 'nema-l6-30r' TYPE_NEMA_L650R = 'nema-l6-50r' + TYPE_NEMA_L1030R = 'nema-l10-30r' TYPE_NEMA_L1420R = 'nema-l14-20r' TYPE_NEMA_L1430R = 'nema-l14-30r' + TYPE_NEMA_L1450R = 'nema-l14-50r' + TYPE_NEMA_L1460R = 'nema-l14-60r' + TYPE_NEMA_L1520R = 'nema-l15-20r' + TYPE_NEMA_L1530R = 'nema-l15-30r' + TYPE_NEMA_L1550R = 'nema-l15-50r' + TYPE_NEMA_L1560R = 'nema-l15-60r' TYPE_NEMA_L2120R = 'nema-l21-20r' TYPE_NEMA_L2130R = 'nema-l21-30r' # California style @@ -442,6 +507,10 @@ class PowerOutletTypeChoices(ChoiceSet): TYPE_ITA_M = 'ita-m' TYPE_ITA_N = 'ita-n' TYPE_ITA_O = 'ita-o' + # USB + TYPE_USB_A = 'usb-a' + TYPE_USB_MICROB = 'usb-micro-b' + TYPE_USB_C = 'usb-c' # Proprietary TYPE_HDOT_CX = 'hdot-cx' @@ -468,6 +537,7 @@ class PowerOutletTypeChoices(ChoiceSet): (TYPE_IEC_3PNE9H, '3P+N+E 9H'), )), ('NEMA (Non-locking)', ( + (TYPE_NEMA_115R, 'NEMA 1-15R'), (TYPE_NEMA_515R, 'NEMA 5-15R'), (TYPE_NEMA_520R, 'NEMA 5-20R'), (TYPE_NEMA_530R, 'NEMA 5-30R'), @@ -476,17 +546,37 @@ class PowerOutletTypeChoices(ChoiceSet): (TYPE_NEMA_620R, 'NEMA 6-20R'), (TYPE_NEMA_630R, 'NEMA 6-30R'), (TYPE_NEMA_650R, 'NEMA 6-50R'), + (TYPE_NEMA_1030R, 'NEMA 10-30R'), + (TYPE_NEMA_1050R, 'NEMA 10-50R'), + (TYPE_NEMA_1420R, 'NEMA 14-20R'), + (TYPE_NEMA_1430R, 'NEMA 14-30R'), + (TYPE_NEMA_1450R, 'NEMA 14-50R'), + (TYPE_NEMA_1460R, 'NEMA 14-60R'), + (TYPE_NEMA_1515R, 'NEMA 15-15R'), + (TYPE_NEMA_1520R, 'NEMA 15-20R'), + (TYPE_NEMA_1530R, 'NEMA 15-30R'), + (TYPE_NEMA_1550R, 'NEMA 15-50R'), + (TYPE_NEMA_1560R, 'NEMA 15-60R'), )), ('NEMA (Locking)', ( + (TYPE_NEMA_L115R, 'NEMA L1-15R'), (TYPE_NEMA_L515R, 'NEMA L5-15R'), (TYPE_NEMA_L520R, 'NEMA L5-20R'), (TYPE_NEMA_L530R, 'NEMA L5-30R'), + (TYPE_NEMA_L550R, 'NEMA L5-50R'), (TYPE_NEMA_L615R, 'NEMA L6-15R'), (TYPE_NEMA_L620R, 'NEMA L6-20R'), (TYPE_NEMA_L630R, 'NEMA L6-30R'), (TYPE_NEMA_L650R, 'NEMA L6-50R'), + (TYPE_NEMA_L1030R, 'NEMA L10-30R'), (TYPE_NEMA_L1420R, 'NEMA L14-20R'), (TYPE_NEMA_L1430R, 'NEMA L14-30R'), + (TYPE_NEMA_L1450R, 'NEMA L14-50R'), + (TYPE_NEMA_L1460R, 'NEMA L14-60R'), + (TYPE_NEMA_L1520R, 'NEMA L15-20R'), + (TYPE_NEMA_L1530R, 'NEMA L15-30R'), + (TYPE_NEMA_L1550R, 'NEMA L15-50R'), + (TYPE_NEMA_L1560R, 'NEMA L15-60R'), (TYPE_NEMA_L2120R, 'NEMA L21-20R'), (TYPE_NEMA_L2130R, 'NEMA L21-30R'), )), @@ -511,6 +601,11 @@ class PowerOutletTypeChoices(ChoiceSet): (TYPE_ITA_N, 'ITA Type N'), (TYPE_ITA_O, 'ITA Type O'), )), + ('USB', ( + (TYPE_USB_A, 'USB Type A'), + (TYPE_USB_MICROB, 'USB Micro B'), + (TYPE_USB_C, 'USB Type C'), + )), ('Proprietary', ( (TYPE_HDOT_CX, 'HDOT Cx'), )), @@ -529,12 +624,6 @@ class PowerOutletFeedLegChoices(ChoiceSet): (FEED_LEG_C, 'C'), ) - LEGACY_MAP = { - FEED_LEG_A: 1, - FEED_LEG_B: 2, - FEED_LEG_C: 3, - } - # # Interfaces @@ -766,81 +855,6 @@ class InterfaceTypeChoices(ChoiceSet): ), ) - LEGACY_MAP = { - TYPE_VIRTUAL: 0, - TYPE_LAG: 200, - TYPE_100ME_FIXED: 800, - TYPE_1GE_FIXED: 1000, - TYPE_1GE_GBIC: 1050, - TYPE_1GE_SFP: 1100, - TYPE_2GE_FIXED: 1120, - TYPE_5GE_FIXED: 1130, - TYPE_10GE_FIXED: 1150, - TYPE_10GE_CX4: 1170, - TYPE_10GE_SFP_PLUS: 1200, - TYPE_10GE_XFP: 1300, - TYPE_10GE_XENPAK: 1310, - TYPE_10GE_X2: 1320, - TYPE_25GE_SFP28: 1350, - TYPE_40GE_QSFP_PLUS: 1400, - TYPE_50GE_QSFP28: 1420, - TYPE_100GE_CFP: 1500, - TYPE_100GE_CFP2: 1510, - TYPE_100GE_CFP4: 1520, - TYPE_100GE_CPAK: 1550, - TYPE_100GE_QSFP28: 1600, - TYPE_200GE_CFP2: 1650, - TYPE_200GE_QSFP56: 1700, - TYPE_400GE_QSFP_DD: 1750, - TYPE_400GE_OSFP: 1800, - TYPE_80211A: 2600, - TYPE_80211G: 2610, - TYPE_80211N: 2620, - TYPE_80211AC: 2630, - TYPE_80211AD: 2640, - TYPE_GSM: 2810, - TYPE_CDMA: 2820, - TYPE_LTE: 2830, - TYPE_SONET_OC3: 6100, - TYPE_SONET_OC12: 6200, - TYPE_SONET_OC48: 6300, - TYPE_SONET_OC192: 6400, - TYPE_SONET_OC768: 6500, - TYPE_SONET_OC1920: 6600, - TYPE_SONET_OC3840: 6700, - TYPE_1GFC_SFP: 3010, - TYPE_2GFC_SFP: 3020, - TYPE_4GFC_SFP: 3040, - TYPE_8GFC_SFP_PLUS: 3080, - TYPE_16GFC_SFP_PLUS: 3160, - TYPE_32GFC_SFP28: 3320, - TYPE_128GFC_QSFP28: 3400, - TYPE_INFINIBAND_SDR: 7010, - TYPE_INFINIBAND_DDR: 7020, - TYPE_INFINIBAND_QDR: 7030, - TYPE_INFINIBAND_FDR10: 7040, - TYPE_INFINIBAND_FDR: 7050, - TYPE_INFINIBAND_EDR: 7060, - TYPE_INFINIBAND_HDR: 7070, - TYPE_INFINIBAND_NDR: 7080, - TYPE_INFINIBAND_XDR: 7090, - TYPE_T1: 4000, - TYPE_E1: 4010, - TYPE_T3: 4040, - TYPE_E3: 4050, - TYPE_STACKWISE: 5000, - TYPE_STACKWISE_PLUS: 5050, - TYPE_FLEXSTACK: 5100, - TYPE_FLEXSTACK_PLUS: 5150, - TYPE_JUNIPER_VCP: 5200, - TYPE_SUMMITSTACK: 5300, - TYPE_SUMMITSTACK128: 5310, - TYPE_SUMMITSTACK256: 5320, - TYPE_SUMMITSTACK512: 5330, - TYPE_OTHER: 32767, - TYPE_KEYSTONE: 32766, - } - class InterfaceModeChoices(ChoiceSet): @@ -854,12 +868,6 @@ class InterfaceModeChoices(ChoiceSet): (MODE_TAGGED_ALL, 'Tagged (All)'), ) - LEGACY_MAP = { - MODE_ACCESS: 100, - MODE_TAGGED: 200, - MODE_TAGGED_ALL: 300, - } - # # FrontPorts/RearPorts @@ -868,6 +876,9 @@ class InterfaceModeChoices(ChoiceSet): class PortTypeChoices(ChoiceSet): TYPE_8P8C = '8p8c' + TYPE_8P6C = '8p6c' + TYPE_8P4C = '8p4c' + TYPE_8P2C = '8p2c' TYPE_110_PUNCH = '110-punch' TYPE_BNC = 'bnc' TYPE_MRJ21 = 'mrj21' @@ -881,12 +892,18 @@ class PortTypeChoices(ChoiceSet): TYPE_MPO = 'mpo' TYPE_LSH = 'lsh' TYPE_LSH_APC = 'lsh-apc' + TYPE_SPLICE = 'splice' + TYPE_CS = 'cs' + TYPE_SN = 'sn' CHOICES = ( ( 'Copper', ( (TYPE_8P8C, '8P8C'), + (TYPE_8P6C, '8P6C'), + (TYPE_8P4C, '8P4C'), + (TYPE_8P2C, '8P2C'), (TYPE_110_PUNCH, '110 Punch'), (TYPE_BNC, 'BNC'), (TYPE_MRJ21, 'MRJ21'), @@ -905,26 +922,13 @@ class PortTypeChoices(ChoiceSet): (TYPE_SC, 'SC'), (TYPE_SC_APC, 'SC/APC'), (TYPE_ST, 'ST'), + (TYPE_CS, 'CS'), + (TYPE_SN, 'SN'), + (TYPE_SPLICE, 'Splice'), ) ) ) - LEGACY_MAP = { - TYPE_8P8C: 1000, - TYPE_110_PUNCH: 1100, - TYPE_BNC: 1200, - TYPE_ST: 2000, - TYPE_SC: 2100, - TYPE_SC_APC: 2110, - TYPE_FC: 2200, - TYPE_LC: 2300, - TYPE_LC_APC: 2310, - TYPE_MTRJ: 2400, - TYPE_MPO: 2500, - TYPE_LSH: 2600, - TYPE_LSH_APC: 2610, - } - # # Cables @@ -984,28 +988,6 @@ class CableTypeChoices(ChoiceSet): (TYPE_POWER, 'Power'), ) - LEGACY_MAP = { - TYPE_CAT3: 1300, - TYPE_CAT5: 1500, - TYPE_CAT5E: 1510, - TYPE_CAT6: 1600, - TYPE_CAT6A: 1610, - TYPE_CAT7: 1700, - TYPE_DAC_ACTIVE: 1800, - TYPE_DAC_PASSIVE: 1810, - TYPE_COAXIAL: 1900, - TYPE_MMF: 3000, - TYPE_MMF_OM1: 3010, - TYPE_MMF_OM2: 3020, - TYPE_MMF_OM3: 3030, - TYPE_MMF_OM4: 3040, - TYPE_SMF: 3500, - TYPE_SMF_OS1: 3510, - TYPE_SMF_OS2: 3520, - TYPE_AOC: 3800, - TYPE_POWER: 5000, - } - class CableStatusChoices(ChoiceSet): @@ -1019,9 +1001,10 @@ class CableStatusChoices(ChoiceSet): (STATUS_DECOMMISSIONING, 'Decommissioning'), ) - LEGACY_MAP = { - STATUS_CONNECTED: True, - STATUS_PLANNED: False, + CSS_CLASSES = { + STATUS_CONNECTED: 'success', + STATUS_PLANNED: 'info', + STATUS_DECOMMISSIONING: 'warning', } @@ -1039,13 +1022,6 @@ class CableLengthUnitChoices(ChoiceSet): (UNIT_INCH, 'Inches'), ) - LEGACY_MAP = { - UNIT_METER: 1200, - UNIT_CENTIMETER: 1100, - UNIT_FOOT: 2100, - UNIT_INCH: 2000, - } - # # PowerFeeds @@ -1065,11 +1041,11 @@ class PowerFeedStatusChoices(ChoiceSet): (STATUS_FAILED, 'Failed'), ) - LEGACY_MAP = { - STATUS_OFFLINE: 0, - STATUS_ACTIVE: 1, - STATUS_PLANNED: 2, - STATUS_FAILED: 4, + CSS_CLASSES = { + STATUS_OFFLINE: 'warning', + STATUS_ACTIVE: 'success', + STATUS_PLANNED: 'info', + STATUS_FAILED: 'danger', } @@ -1083,9 +1059,9 @@ class PowerFeedTypeChoices(ChoiceSet): (TYPE_REDUNDANT, 'Redundant'), ) - LEGACY_MAP = { - TYPE_PRIMARY: 1, - TYPE_REDUNDANT: 2, + CSS_CLASSES = { + TYPE_PRIMARY: 'success', + TYPE_REDUNDANT: 'info', } @@ -1099,11 +1075,6 @@ class PowerFeedSupplyChoices(ChoiceSet): (SUPPLY_DC, 'DC'), ) - LEGACY_MAP = { - SUPPLY_AC: 1, - SUPPLY_DC: 2, - } - class PowerFeedPhaseChoices(ChoiceSet): @@ -1114,8 +1085,3 @@ class PowerFeedPhaseChoices(ChoiceSet): (PHASE_SINGLE, 'Single phase'), (PHASE_3PHASE, 'Three-phase'), ) - - LEGACY_MAP = { - PHASE_SINGLE: 1, - PHASE_3PHASE: 3, - } diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index f938b6f14..0fc69be3b 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -11,8 +11,6 @@ RACK_U_HEIGHT_DEFAULT = 42 RACK_ELEVATION_BORDER_WIDTH = 2 RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30 -RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 220 -RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 22 # @@ -20,7 +18,7 @@ RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 22 # REARPORT_POSITIONS_MIN = 1 -REARPORT_POSITIONS_MAX = 64 +REARPORT_POSITIONS_MAX = 1024 # @@ -61,12 +59,6 @@ POWERFEED_MAX_UTILIZATION_DEFAULT = 80 # Percentage # Cabling and connections # -# Console/power/interface connection statuses -CONNECTION_STATUS_CHOICES = [ - [False, 'Not Connected'], - [True, 'Connected'], -] - # Cable endpoint types CABLE_TERMINATION_MODELS = Q( Q(app_label='circuits', model__in=( @@ -85,12 +77,13 @@ CABLE_TERMINATION_MODELS = Q( ) COMPATIBLE_TERMINATION_TYPES = { + 'circuittermination': ['interface', 'frontport', 'rearport', 'circuittermination'], 'consoleport': ['consoleserverport', 'frontport', 'rearport'], 'consoleserverport': ['consoleport', 'frontport', 'rearport'], - 'powerport': ['poweroutlet', 'powerfeed'], - 'poweroutlet': ['powerport'], 'interface': ['interface', 'circuittermination', 'frontport', 'rearport'], 'frontport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'], + 'powerfeed': ['powerport'], + 'poweroutlet': ['powerport'], + 'powerport': ['poweroutlet', 'powerfeed'], 'rearport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'], - 'circuittermination': ['interface', 'frontport', 'rearport', 'circuittermination'], } diff --git a/netbox/dcim/elevations.py b/netbox/dcim/elevations.py index ea780b2d9..93c44f087 100644 --- a/netbox/dcim/elevations.py +++ b/netbox/dcim/elevations.py @@ -14,10 +14,11 @@ class RackElevationSVG: Use this class to render a rack elevation as an SVG image. :param rack: A NetBox Rack instance + :param user: User instance. If specified, only devices viewable by this user will be fully displayed. :param include_images: If true, the SVG document will embed front/rear device face images, where available :param base_url: Base URL for links within the SVG document. If none, links will be relative. """ - def __init__(self, rack, include_images=True, base_url=None): + def __init__(self, rack, user=None, include_images=True, base_url=None): self.rack = rack self.include_images = include_images if base_url is not None: @@ -25,7 +26,14 @@ class RackElevationSVG: else: self.base_url = '' - def _get_device_description(self, device): + # Determine the subset of devices within this rack that are viewable by the user, if any + permitted_devices = self.rack.devices + if user is not None: + permitted_devices = permitted_devices.restrict(user, 'view') + self.permitted_device_ids = permitted_devices.values_list('pk', flat=True) + + @staticmethod + def _get_device_description(device): return '{} ({}) — {} ({}U) {} {}'.format( device.name, device.device_role, @@ -86,8 +94,12 @@ class RackElevationSVG: # Embed front device type image if one exists if self.include_images and device.device_type.front_image: - url = '{}{}'.format(self.base_url, device.device_type.front_image.url) - image = drawing.image(href=url, insert=start, size=end, class_='device-image') + image = drawing.image( + href=device.device_type.front_image.url, + insert=start, + size=end, + class_='device-image' + ) image.fit(scale='slice') link.add(image) @@ -99,8 +111,12 @@ class RackElevationSVG: # Embed rear device type image if one exists if self.include_images and device.device_type.rear_image: - url = device.device_type.rear_image.url - image = drawing.image(href=url, insert=start, size=end, class_='device-image') + image = drawing.image( + href=device.device_type.rear_image.url, + insert=start, + size=end, + class_='device-image' + ) image.fit(scale='slice') drawing.add(image) @@ -133,7 +149,7 @@ class RackElevationSVG: unit_cursor = 0 for u in elevation: o = other[unit_cursor] - if not u['device'] and o['device']: + if not u['device'] and o['device'] and o['device'].device_type.is_full_depth: u['device'] = o['device'] u['height'] = 1 unit_cursor += u.get('height', 1) @@ -174,10 +190,13 @@ class RackElevationSVG: text_cordinates = (x_offset + (unit_width / 2), y_offset + end_y / 2) # Draw the device - if device and device.face == face: + if device and device.face == face and device.pk in self.permitted_device_ids: self._draw_device_front(drawing, device, start_cordinates, end_cordinates, text_cordinates) - elif device and device.device_type.is_full_depth: + elif device and device.device_type.is_full_depth and device.pk in self.permitted_device_ids: self._draw_device_rear(drawing, device, start_cordinates, end_cordinates, text_cordinates) + elif device: + # Devices which the user does not have permission to view are rendered only as unavailable space + drawing.add(drawing.rect(start_cordinates, end_cordinates, class_='blocked')) else: # Draw shallow devices, reservations, or empty units class_ = 'slot' diff --git a/netbox/dcim/exceptions.py b/netbox/dcim/exceptions.py deleted file mode 100644 index 18e42318b..000000000 --- a/netbox/dcim/exceptions.py +++ /dev/null @@ -1,14 +0,0 @@ -class LoopDetected(Exception): - """ - A loop has been detected while tracing a cable path. - """ - pass - - -class CableTraceSplit(Exception): - """ - A cable trace cannot be completed because a RearPort maps to multiple FrontPorts and - we don't know which one to follow. - """ - def __init__(self, termination, *args, **kwargs): - self.termination = termination diff --git a/netbox/dcim/fields.py b/netbox/dcim/fields.py index 3acd0d4a1..21af2ed14 100644 --- a/netbox/dcim/fields.py +++ b/netbox/dcim/fields.py @@ -1,9 +1,11 @@ +from django.contrib.postgres.fields import ArrayField 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 from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN +from .lookups import PathContains class ASNField(models.BigIntegerField): @@ -50,3 +52,15 @@ class MACAddressField(models.Field): if not value: return None return str(self.to_python(value)) + + +class PathField(ArrayField): + """ + An ArrayField which holds a set of objects, each identified by a (type, ID) tuple. + """ + def __init__(self, **kwargs): + kwargs['base_field'] = models.CharField(max_length=40) + super().__init__(**kwargs) + + +PathField.register_lookup(PathContains) diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 39d684d55..0fb4b7334 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -1,7 +1,8 @@ import django_filters from django.contrib.auth.models import User +from django.db.models import Count -from extras.filters import CustomFieldFilterSet, LocalConfigContextFilterSet, CreatedUpdatedFilterSet +from extras.filters import CustomFieldModelFilterSet, LocalConfigContextFilterSet, CreatedUpdatedFilterSet from tenancy.filters import TenancyFilterSet from tenancy.models import Tenant from utilities.choices import ColorChoices @@ -23,6 +24,7 @@ from .models import ( __all__ = ( 'CableFilterSet', + 'CableTerminationFilterSet', 'ConsoleConnectionFilterSet', 'ConsolePortFilterSet', 'ConsolePortTemplateFilterSet', @@ -40,6 +42,7 @@ __all__ = ( 'InterfaceTemplateFilterSet', 'InventoryItemFilterSet', 'ManufacturerFilterSet', + 'PathEndpointFilterSet', 'PlatformFilterSet', 'PowerConnectionFilterSet', 'PowerFeedFilterSet', @@ -60,7 +63,7 @@ __all__ = ( ) -class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CustomFieldFilterSet): +class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CustomFieldModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=Region.objects.all(), label='Parent region (ID)', @@ -77,7 +80,7 @@ class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CustomFieldFilterS fields = ['id', 'name', 'slug', 'description'] -class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): +class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -176,7 +179,7 @@ class RackRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet): fields = ['id', 'name', 'slug', 'color'] -class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): +class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -221,6 +224,12 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Creat choices=RackStatusChoices, null_value=None ) + type = django_filters.MultipleChoiceFilter( + choices=RackTypeChoices + ) + width = django_filters.MultipleChoiceFilter( + choices=RackWidthChoices + ) role_id = django_filters.ModelMultipleChoiceFilter( queryset=RackRole.objects.all(), label='Role (ID)', @@ -239,8 +248,8 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Creat class Meta: model = Rack fields = [ - 'id', 'name', 'facility_id', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', - 'outer_width', 'outer_depth', 'outer_unit', + 'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'desc_units', 'outer_width', 'outer_depth', + 'outer_unit', ] def search(self, queryset, name, value): @@ -293,11 +302,12 @@ class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet): label='User (ID)', ) user = django_filters.ModelMultipleChoiceFilter( - field_name='user', + field_name='user__username', queryset=User.objects.all(), to_field_name='username', label='User (name)', ) + tag = TagFilter() class Meta: model = RackReservation @@ -321,7 +331,7 @@ class ManufacturerFilterSet(BaseFilterSet, NameSlugSearchFilterSet): fields = ['id', 'name', 'slug', 'description'] -class DeviceTypeFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): +class DeviceTypeFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -383,28 +393,28 @@ class DeviceTypeFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFil ) def _console_ports(self, queryset, name, value): - return queryset.exclude(consoleport_templates__isnull=value) + return queryset.exclude(consoleporttemplates__isnull=value) def _console_server_ports(self, queryset, name, value): - return queryset.exclude(consoleserverport_templates__isnull=value) + return queryset.exclude(consoleserverporttemplates__isnull=value) def _power_ports(self, queryset, name, value): - return queryset.exclude(powerport_templates__isnull=value) + return queryset.exclude(powerporttemplates__isnull=value) def _power_outlets(self, queryset, name, value): - return queryset.exclude(poweroutlet_templates__isnull=value) + return queryset.exclude(poweroutlettemplates__isnull=value) def _interfaces(self, queryset, name, value): - return queryset.exclude(interface_templates__isnull=value) + return queryset.exclude(interfacetemplates__isnull=value) def _pass_through_ports(self, queryset, name, value): return queryset.exclude( - frontport_templates__isnull=value, - rearport_templates__isnull=value + frontporttemplates__isnull=value, + rearporttemplates__isnull=value ) def _device_bays(self, queryset, name, value): - return queryset.exclude(device_bay_templates__isnull=value) + return queryset.exclude(devicebaytemplates__isnull=value) class DeviceTypeComponentFilterSet(NameSlugSearchFilterSet): @@ -500,7 +510,7 @@ class DeviceFilterSet( BaseFilterSet, TenancyFilterSet, LocalConfigContextFilterSet, - CustomFieldFilterSet, + CustomFieldModelFilterSet, CreatedUpdatedFilterSet ): q = django_filters.CharFilter( @@ -655,22 +665,16 @@ class DeviceFilterSet( return queryset.filter( Q(name__icontains=value) | Q(serial__icontains=value.strip()) | - Q(inventory_items__serial__icontains=value.strip()) | + Q(inventoryitems__serial__icontains=value.strip()) | Q(asset_tag__icontains=value.strip()) | Q(comments__icontains=value) ).distinct() def _has_primary_ip(self, queryset, name, value): + params = Q(primary_ip4__isnull=False) | Q(primary_ip6__isnull=False) if value: - return queryset.filter( - Q(primary_ip4__isnull=False) | - Q(primary_ip6__isnull=False) - ) - else: - return queryset.exclude( - Q(primary_ip4__isnull=False) | - Q(primary_ip6__isnull=False) - ) + return queryset.filter(params) + return queryset.exclude(params) def _virtual_chassis_member(self, queryset, name, value): return queryset.exclude(virtual_chassis__isnull=value) @@ -697,7 +701,7 @@ class DeviceFilterSet( ) def _device_bays(self, queryset, name, value): - return queryset.exclude(device_bays__isnull=value) + return queryset.exclude(devicebays__isnull=value) class DeviceComponentFilterSet(django_filters.FilterSet): @@ -746,75 +750,81 @@ class DeviceComponentFilterSet(django_filters.FilterSet): return queryset return queryset.filter( Q(name__icontains=value) | + Q(label__icontains=value) | Q(description__icontains=value) ) -class ConsolePortFilterSet(BaseFilterSet, DeviceComponentFilterSet): - type = django_filters.MultipleChoiceFilter( - choices=ConsolePortTypeChoices, - null_value=None - ) +class CableTerminationFilterSet(django_filters.FilterSet): cabled = django_filters.BooleanFilter( field_name='cable', lookup_expr='isnull', exclude=True ) + +class PathEndpointFilterSet(django_filters.FilterSet): + connected = django_filters.BooleanFilter( + method='filter_connected' + ) + + def filter_connected(self, queryset, name, value): + if value: + return queryset.filter(_path__is_active=True) + else: + return queryset.filter(Q(_path__isnull=True) | Q(_path__is_active=False)) + + +class ConsolePortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): + type = django_filters.MultipleChoiceFilter( + choices=ConsolePortTypeChoices, + null_value=None + ) + class Meta: model = ConsolePort - fields = ['id', 'name', 'description', 'connection_status'] + fields = ['id', 'name', 'description'] -class ConsoleServerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet): +class ConsoleServerPortFilterSet( + BaseFilterSet, + DeviceComponentFilterSet, + CableTerminationFilterSet, + PathEndpointFilterSet +): type = django_filters.MultipleChoiceFilter( choices=ConsolePortTypeChoices, null_value=None ) - cabled = django_filters.BooleanFilter( - field_name='cable', - lookup_expr='isnull', - exclude=True - ) class Meta: model = ConsoleServerPort - fields = ['id', 'name', 'description', 'connection_status'] + fields = ['id', 'name', 'description'] -class PowerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet): +class PowerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): type = django_filters.MultipleChoiceFilter( choices=PowerPortTypeChoices, null_value=None ) - cabled = django_filters.BooleanFilter( - field_name='cable', - lookup_expr='isnull', - exclude=True - ) class Meta: model = PowerPort - fields = ['id', 'name', 'maximum_draw', 'allocated_draw', 'description', 'connection_status'] + fields = ['id', 'name', 'maximum_draw', 'allocated_draw', 'description'] -class PowerOutletFilterSet(BaseFilterSet, DeviceComponentFilterSet): +class PowerOutletFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): type = django_filters.MultipleChoiceFilter( choices=PowerOutletTypeChoices, null_value=None ) - cabled = django_filters.BooleanFilter( - field_name='cable', - lookup_expr='isnull', - exclude=True - ) class Meta: model = PowerOutlet - fields = ['id', 'name', 'feed_leg', 'description', 'connection_status'] + fields = ['id', 'name', 'feed_leg', 'description'] -class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet): +class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -831,11 +841,6 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet): field_name='pk', label='Device (ID)', ) - cabled = django_filters.BooleanFilter( - field_name='cable', - lookup_expr='isnull', - exclude=True - ) kind = django_filters.CharFilter( method='filter_kind', label='Kind of interface', @@ -862,7 +867,7 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet): class Meta: model = Interface - fields = ['id', 'name', 'connection_status', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'description'] + fields = ['id', 'name', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'description'] def filter_device(self, queryset, name, value): try: @@ -912,24 +917,14 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet): }.get(value, queryset.none()) -class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet): - cabled = django_filters.BooleanFilter( - field_name='cable', - lookup_expr='isnull', - exclude=True - ) +class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet): class Meta: model = FrontPort fields = ['id', 'name', 'type', 'description'] -class RearPortFilterSet(BaseFilterSet, DeviceComponentFilterSet): - cabled = django_filters.BooleanFilter( - field_name='cable', - lookup_expr='isnull', - exclude=True - ) +class RearPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet): class Meta: model = RearPort @@ -1066,7 +1061,8 @@ class VirtualChassisFilterSet(BaseFilterSet): if not value.strip(): return queryset qs_filter = ( - Q(master__name__icontains=value) | + Q(name__icontains=value) | + Q(members__name__icontains=value) | Q(domain__icontains=value) ) return queryset.filter(qs_filter) @@ -1117,6 +1113,7 @@ class CableFilterSet(BaseFilterSet): method='filter_device', field_name='device__tenant__slug' ) + tag = TagFilter() class Meta: model = Cable @@ -1135,7 +1132,20 @@ class CableFilterSet(BaseFilterSet): return queryset -class ConsoleConnectionFilterSet(BaseFilterSet): +class ConnectionFilterSet: + + def filter_site(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter(device__site__slug=value) + + def filter_device(self, queryset, name, value): + if not value: + return queryset + return queryset.filter(device_id__in=value) + + +class ConsoleConnectionFilterSet(ConnectionFilterSet, BaseFilterSet): site = django_filters.CharFilter( method='filter_site', label='Site (slug)', @@ -1150,23 +1160,10 @@ class ConsoleConnectionFilterSet(BaseFilterSet): class Meta: model = ConsolePort - fields = ['name', 'connection_status'] - - def filter_site(self, queryset, name, value): - if not value.strip(): - return queryset - return queryset.filter(connected_endpoint__device__site__slug=value) - - def filter_device(self, queryset, name, value): - if not value: - return queryset - return queryset.filter( - Q(**{'{}__in'.format(name): value}) | - Q(**{'connected_endpoint__{}__in'.format(name): value}) - ) + fields = ['name'] -class PowerConnectionFilterSet(BaseFilterSet): +class PowerConnectionFilterSet(ConnectionFilterSet, BaseFilterSet): site = django_filters.CharFilter( method='filter_site', label='Site (slug)', @@ -1181,23 +1178,10 @@ class PowerConnectionFilterSet(BaseFilterSet): class Meta: model = PowerPort - fields = ['name', 'connection_status'] - - def filter_site(self, queryset, name, value): - if not value.strip(): - return queryset - return queryset.filter(_connected_poweroutlet__device__site__slug=value) - - def filter_device(self, queryset, name, value): - if not value: - return queryset - return queryset.filter( - Q(**{'{}__in'.format(name): value}) | - Q(**{'_connected_poweroutlet__{}__in'.format(name): value}) - ) + fields = ['name'] -class InterfaceConnectionFilterSet(BaseFilterSet): +class InterfaceConnectionFilterSet(ConnectionFilterSet, BaseFilterSet): site = django_filters.CharFilter( method='filter_site', label='Site (slug)', @@ -1212,23 +1196,7 @@ class InterfaceConnectionFilterSet(BaseFilterSet): class Meta: model = Interface - fields = ['connection_status'] - - def filter_site(self, queryset, name, value): - if not value.strip(): - return queryset - return queryset.filter( - Q(device__site__slug=value) | - Q(_connected_interface__device__site__slug=value) - ) - - def filter_device(self, queryset, name, value): - if not value: - return queryset - return queryset.filter( - Q(**{'{}__in'.format(name): value}) | - Q(**{'_connected_interface__{}__in'.format(name): value}) - ) + fields = [] class PowerPanelFilterSet(BaseFilterSet): @@ -1265,6 +1233,7 @@ class PowerPanelFilterSet(BaseFilterSet): lookup_expr='in', label='Rack group (ID)', ) + tag = TagFilter() class Meta: model = PowerPanel @@ -1279,7 +1248,13 @@ class PowerPanelFilterSet(BaseFilterSet): return queryset.filter(qs_filter) -class PowerFeedFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): +class PowerFeedFilterSet( + BaseFilterSet, + CableTerminationFilterSet, + PathEndpointFilterSet, + CustomFieldModelFilterSet, + CreatedUpdatedFilterSet +): q = django_filters.CharFilter( method='search', label='Search', diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index f421c2e81..fcc5a3ac2 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -6,28 +6,28 @@ from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.forms.array import SimpleArrayField from django.core.exceptions import ObjectDoesNotExist from django.utils.safestring import mark_safe -from mptt.forms import TreeNodeChoiceField from netaddr import EUI from netaddr.core import AddrFormatError from timezone_field import TimeZoneFormField -from circuits.models import Circuit, Provider +from circuits.models import Circuit, CircuitTermination, Provider from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldFilterForm, CustomFieldModelForm, - LocalConfigContextFilterForm, TagField, + LocalConfigContextFilterForm, ) +from extras.models import Tag from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN from ipam.models import IPAddress, VLAN from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant, TenantGroup from utilities.forms import ( - APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, - BulkEditNullBooleanSelect, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, - CSVModelForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, - JSONField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, + APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, + ColorSelect, CommentField, CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelForm, + DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, + NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) -from virtualization.models import Cluster, ClusterGroup, VirtualMachine +from virtualization.models import Cluster, ClusterGroup from .choices import * from .constants import * from .models import ( @@ -59,7 +59,6 @@ def get_device_by_name_or_pk(name): class DeviceComponentFilterForm(BootstrapMixin, forms.Form): - field_order = [ 'q', 'region', 'site' ] @@ -70,39 +69,32 @@ class DeviceComponentFilterForm(BootstrapMixin, forms.Form): region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', - required=False, - widget=APISelectMultiple( - value_field='slug', - filter_for={ - 'site': 'region' - } - ) + required=False ) site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', required=False, - widget=APISelectMultiple( - value_field="slug", - filter_for={ - 'device_id': 'site', - } - ) + query_params={ + 'region': '$region' + } ) device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, - label='Device' + label='Device', + query_params={ + 'site': '$site' + } ) -class InterfaceCommonForm: +class InterfaceCommonForm(forms.Form): def clean(self): - super().clean() - # Validate VLAN assignments + parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine' tagged_vlans = self.cleaned_data['tagged_vlans'] # Untagged interfaces cannot be assigned tagged VLANs @@ -117,38 +109,42 @@ class InterfaceCommonForm: # Validate tagged VLANs; must be a global VLAN or in the same site elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED: - valid_sites = [None, self.cleaned_data['device'].site] + valid_sites = [None, self.cleaned_data[parent_field].site] invalid_vlans = [str(v) for v in tagged_vlans if v.site not in valid_sites] if invalid_vlans: raise forms.ValidationError({ - 'tagged_vlans': "The tagged VLANs ({}) must belong to the same site as the interface's parent " - "device/VM, or they must be global".format(', '.join(invalid_vlans)) + 'tagged_vlans': f"The tagged VLANs ({', '.join(invalid_vlans)}) must belong to the same site as " + f"the interface's parent device/VM, or they must be global" }) -class BulkRenameForm(forms.Form): +class ComponentForm(BootstrapMixin, forms.Form): """ - An extendable form to be used for renaming device components in bulk. + Subclass this form when facilitating the creation of one or more device component or component templates based on + a name pattern. """ - find = forms.CharField() - replace = forms.CharField() - use_regex = forms.BooleanField( + name_pattern = ExpandableNameField( + label='Name' + ) + label_pattern = ExpandableNameField( + label='Label', required=False, - initial=True, - label='Use regular expressions' + help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)' ) def clean(self): + super().clean() - # Validate regular expression in "find" field - if self.cleaned_data['use_regex']: - try: - re.compile(self.cleaned_data['find']) - except re.error: + # Validate that the number of components being created from both the name_pattern and label_pattern are equal + if self.cleaned_data['label_pattern']: + name_pattern_count = len(self.cleaned_data['name_pattern']) + label_pattern_count = len(self.cleaned_data['label_pattern']) + if name_pattern_count != label_pattern_count: raise forms.ValidationError({ - 'find': "Invalid regular expression" - }) + 'label_pattern': f'The provided name pattern will create {name_pattern_count} components, however ' + f'{label_pattern_count} labels will be generated. These counts must match.' + }, code='label_pattern_mismatch') # @@ -178,10 +174,9 @@ class MACAddressField(forms.Field): # class RegionForm(BootstrapMixin, CustomFieldModelForm): - parent = TreeNodeChoiceField( + parent = DynamicModelChoiceField( queryset=Region.objects.all(), - required=False, - widget=StaticSelect2() + required=False ) slug = SlugField() @@ -218,14 +213,14 @@ class RegionFilterForm(BootstrapMixin, CustomFieldFilterForm): # class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): - region = TreeNodeChoiceField( + region = DynamicModelChoiceField( queryset=Region.objects.all(), - required=False, - widget=StaticSelect2() + required=False ) slug = SlugField() comments = CommentField() - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -303,10 +298,9 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor initial='', widget=StaticSelect2() ) - region = TreeNodeChoiceField( + region = DynamicModelChoiceField( queryset=Region.objects.all(), - required=False, - widget=StaticSelect2() + required=False ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), @@ -349,10 +343,7 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', - required=False, - widget=APISelectMultiple( - value_field="slug", - ) + required=False ) tag = TagFilterField(model) @@ -362,19 +353,32 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): # class RackGroupForm(BootstrapMixin, forms.ModelForm): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( - queryset=Site.objects.all() + queryset=Site.objects.all(), + query_params={ + 'region_id': '$region' + } ) parent = DynamicModelChoiceField( queryset=RackGroup.objects.all(), - required=False + required=False, + query_params={ + 'site_id': '$site' + } ) slug = SlugField() class Meta: model = RackGroup fields = ( - 'site', 'parent', 'name', 'slug', 'description', + 'region', 'site', 'parent', 'name', 'slug', 'description', ) @@ -403,34 +407,24 @@ class RackGroupFilterForm(BootstrapMixin, forms.Form): region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', - required=False, - widget=APISelectMultiple( - value_field="slug", - filter_for={ - 'site': 'region', - 'parent': 'region', - } - ) + required=False ) site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', required=False, - widget=APISelectMultiple( - value_field="slug", - filter_for={ - 'parent': 'site', - } - ) + query_params={ + 'region': '$region' + } ) parent = DynamicModelMultipleChoiceField( queryset=RackGroup.objects.all(), to_field_name='slug', required=False, - widget=APISelectMultiple( - api_url="/api/dcim/rack-groups/", - value_field="slug", - ) + query_params={ + 'region': '$region', + 'site': '$site', + } ) @@ -464,32 +458,42 @@ class RackRoleCSVForm(CSVModelForm): # class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - widget=APISelect( - filter_for={ - 'group': 'site_id', - } - ) + query_params={ + 'region_id': '$region' + } ) group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), - required=False + required=False, + query_params={ + 'site_id': '$site' + } ) role = DynamicModelChoiceField( queryset=RackRole.objects.all(), required=False ) comments = CommentField() - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) class Meta: model = Rack fields = [ - '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', + 'region', '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", @@ -566,18 +570,26 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput ) + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, - widget=APISelect( - filter_for={ - 'group': 'site_id', - } - ) + query_params={ + 'region_id': '$region' + } ) group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), - required=False + required=False, + query_params={ + 'site_id': '$site' + } ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), @@ -655,48 +667,45 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', - required=False, - widget=APISelectMultiple( - value_field="slug", - filter_for={ - 'site': 'region' - } - ) + required=False ) site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', required=False, - widget=APISelectMultiple( - value_field="slug", - filter_for={ - 'group_id': 'site' - } - ) + query_params={ + 'region': '$region' + } ) group_id = DynamicModelMultipleChoiceField( - queryset=RackGroup.objects.prefetch_related( - 'site' - ), + queryset=RackGroup.objects.all(), required=False, label='Rack group', - widget=APISelectMultiple( - null_option=True - ) + null_option='None', + query_params={ + 'site': '$site' + } ) status = forms.MultipleChoiceField( choices=RackStatusChoices, required=False, widget=StaticSelect2Multiple() ) + type = forms.MultipleChoiceField( + choices=RackTypeChoices, + required=False, + widget=StaticSelect2Multiple() + ) + width = forms.MultipleChoiceField( + choices=RackWidthChoices, + required=False, + widget=StaticSelect2Multiple() + ) role = DynamicModelMultipleChoiceField( queryset=RackRole.objects.all(), to_field_name='slug', required=False, - widget=APISelectMultiple( - value_field="slug", - null_option=True, - ) + null_option='None' ) tag = TagFilterField(model) @@ -711,38 +720,51 @@ class RackElevationFilterForm(RackFilterForm): queryset=Rack.objects.all(), label='Rack', required=False, - widget=APISelectMultiple( - display_field='display_name', - ) + display_field='display_name', + query_params={ + 'site': '$site', + 'group_id': '$group_id', + } ) - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Filter the rack field based on the site and group - self.fields['site'].widget.add_filter_for('id', 'site') - self.fields['group_id'].widget.add_filter_for('id', 'group_id') - # # Rack reservations # -class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm): - rack = forms.ModelChoiceField( - queryset=Rack.objects.all(), +class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), required=False, - widget=forms.HiddenInput() + initial_params={ + 'sites': '$site' + } ) - # TODO: Change this to an API-backed form field. We can't do this currently because we want to retain - # the multi-line ');this.$searchContainer=t,this.$search=t.find("input");var n=e.call(this);return this._transferTabIndex(),n},e.prototype.bind=function(e,t,n){var i=this,r=t.id+"-results";e.call(this,t,n),t.on("open",function(){i.$search.attr("aria-controls",r),i.$search.trigger("focus")}),t.on("close",function(){i.$search.val(""),i.$search.removeAttr("aria-controls"),i.$search.removeAttr("aria-activedescendant"),i.$search.trigger("focus")}),t.on("enable",function(){i.$search.prop("disabled",!1),i._transferTabIndex()}),t.on("disable",function(){i.$search.prop("disabled",!0)}),t.on("focus",function(e){i.$search.trigger("focus")}),t.on("results:focus",function(e){e.data._resultId?i.$search.attr("aria-activedescendant",e.data._resultId):i.$search.removeAttr("aria-activedescendant")}),this.$selection.on("focusin",".select2-search--inline",function(e){i.trigger("focus",e)}),this.$selection.on("focusout",".select2-search--inline",function(e){i._handleBlur(e)}),this.$selection.on("keydown",".select2-search--inline",function(e){if(e.stopPropagation(),i.trigger("keypress",e),i._keyUpPrevented=e.isDefaultPrevented(),e.which===l.BACKSPACE&&""===i.$search.val()){var t=i.$searchContainer.prev(".select2-selection__choice");if(0this.maximumInputLength?this.trigger("results:message",{message:"inputTooLong",args:{maximum:this.maximumInputLength,input:t.term,params:t}}):e.call(this,t,n)},e}),e.define("select2/data/maximumSelectionLength",[],function(){function e(e,t,n){this.maximumSelectionLength=n.get("maximumSelectionLength"),e.call(this,t,n)}return e.prototype.bind=function(e,t,n){var i=this;e.call(this,t,n),t.on("select",function(){i._checkIfMaximumSelected()})},e.prototype.query=function(e,t,n){var i=this;this._checkIfMaximumSelected(function(){e.call(i,t,n)})},e.prototype._checkIfMaximumSelected=function(e,n){var i=this;this.current(function(e){var t=null!=e?e.length:0;0=i.maximumSelectionLength?i.trigger("results:message",{message:"maximumSelected",args:{maximum:i.maximumSelectionLength}}):n&&n()})},e}),e.define("select2/dropdown",["jquery","./utils"],function(t,e){function n(e,t){this.$element=e,this.options=t,n.__super__.constructor.call(this)}return e.Extend(n,e.Observable),n.prototype.render=function(){var e=t('');return e.attr("dir",this.options.get("dir")),this.$dropdown=e},n.prototype.bind=function(){},n.prototype.position=function(e,t){},n.prototype.destroy=function(){this.$dropdown.remove()},n}),e.define("select2/dropdown/search",["jquery","../utils"],function(o,e){function t(){}return t.prototype.render=function(e){var t=e.call(this),n=o('');return this.$searchContainer=n,this.$search=n.find("input"),t.prepend(n),t},t.prototype.bind=function(e,t,n){var i=this,r=t.id+"-results";e.call(this,t,n),this.$search.on("keydown",function(e){i.trigger("keypress",e),i._keyUpPrevented=e.isDefaultPrevented()}),this.$search.on("input",function(e){o(this).off("keyup")}),this.$search.on("keyup input",function(e){i.handleSearch(e)}),t.on("open",function(){i.$search.attr("tabindex",0),i.$search.attr("aria-controls",r),i.$search.trigger("focus"),window.setTimeout(function(){i.$search.trigger("focus")},0)}),t.on("close",function(){i.$search.attr("tabindex",-1),i.$search.removeAttr("aria-controls"),i.$search.removeAttr("aria-activedescendant"),i.$search.val(""),i.$search.trigger("blur")}),t.on("focus",function(){t.isOpen()||i.$search.trigger("focus")}),t.on("results:all",function(e){null!=e.query.term&&""!==e.query.term||(i.showSearch(e)?i.$searchContainer.removeClass("select2-search--hide"):i.$searchContainer.addClass("select2-search--hide"))}),t.on("results:focus",function(e){e.data._resultId?i.$search.attr("aria-activedescendant",e.data._resultId):i.$search.removeAttr("aria-activedescendant")})},t.prototype.handleSearch=function(e){if(!this._keyUpPrevented){var t=this.$search.val();this.trigger("query",{term:t})}this._keyUpPrevented=!1},t.prototype.showSearch=function(e,t){return!0},t}),e.define("select2/dropdown/hidePlaceholder",[],function(){function e(e,t,n,i){this.placeholder=this.normalizePlaceholder(n.get("placeholder")),e.call(this,t,n,i)}return e.prototype.append=function(e,t){t.results=this.removePlaceholder(t.results),e.call(this,t)},e.prototype.normalizePlaceholder=function(e,t){return"string"==typeof t&&(t={id:"",text:t}),t},e.prototype.removePlaceholder=function(e,t){for(var n=t.slice(0),i=t.length-1;0<=i;i--){var r=t[i];this.placeholder.id===r.id&&n.splice(i,1)}return n},e}),e.define("select2/dropdown/infiniteScroll",["jquery"],function(n){function e(e,t,n,i){this.lastParams={},e.call(this,t,n,i),this.$loadingMore=this.createLoadingMore(),this.loading=!1}return e.prototype.append=function(e,t){this.$loadingMore.remove(),this.loading=!1,e.call(this,t),this.showLoadingMore(t)&&(this.$results.append(this.$loadingMore),this.loadMoreIfNeeded())},e.prototype.bind=function(e,t,n){var i=this;e.call(this,t,n),t.on("query",function(e){i.lastParams=e,i.loading=!0}),t.on("query:append",function(e){i.lastParams=e,i.loading=!0}),this.$results.on("scroll",this.loadMoreIfNeeded.bind(this))},e.prototype.loadMoreIfNeeded=function(){var e=n.contains(document.documentElement,this.$loadingMore[0]);if(!this.loading&&e){var t=this.$results.offset().top+this.$results.outerHeight(!1);this.$loadingMore.offset().top+this.$loadingMore.outerHeight(!1)<=t+50&&this.loadMore()}},e.prototype.loadMore=function(){this.loading=!0;var e=n.extend({},{page:1},this.lastParams);e.page++,this.trigger("query:append",e)},e.prototype.showLoadingMore=function(e,t){return t.pagination&&t.pagination.more},e.prototype.createLoadingMore=function(){var e=n('
  • '),t=this.options.get("translations").get("loadingMore");return e.html(t(this.lastParams)),e},e}),e.define("select2/dropdown/attachBody",["jquery","../utils"],function(f,a){function e(e,t,n){this.$dropdownParent=f(n.get("dropdownParent")||document.body),e.call(this,t,n)}return e.prototype.bind=function(e,t,n){var i=this;e.call(this,t,n),t.on("open",function(){i._showDropdown(),i._attachPositioningHandler(t),i._bindContainerResultHandlers(t)}),t.on("close",function(){i._hideDropdown(),i._detachPositioningHandler(t)}),this.$dropdownContainer.on("mousedown",function(e){e.stopPropagation()})},e.prototype.destroy=function(e){e.call(this),this.$dropdownContainer.remove()},e.prototype.position=function(e,t,n){t.attr("class",n.attr("class")),t.removeClass("select2"),t.addClass("select2-container--open"),t.css({position:"absolute",top:-999999}),this.$container=n},e.prototype.render=function(e){var t=f(""),n=e.call(this);return t.append(n),this.$dropdownContainer=t},e.prototype._hideDropdown=function(e){this.$dropdownContainer.detach()},e.prototype._bindContainerResultHandlers=function(e,t){if(!this._containerResultsHandlersBound){var n=this;t.on("results:all",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("results:append",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("results:message",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("select",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("unselect",function(){n._positionDropdown(),n._resizeDropdown()}),this._containerResultsHandlersBound=!0}},e.prototype._attachPositioningHandler=function(e,t){var n=this,i="scroll.select2."+t.id,r="resize.select2."+t.id,o="orientationchange.select2."+t.id,s=this.$container.parents().filter(a.hasScroll);s.each(function(){a.StoreData(this,"select2-scroll-position",{x:f(this).scrollLeft(),y:f(this).scrollTop()})}),s.on(i,function(e){var t=a.GetData(this,"select2-scroll-position");f(this).scrollTop(t.y)}),f(window).on(i+" "+r+" "+o,function(e){n._positionDropdown(),n._resizeDropdown()})},e.prototype._detachPositioningHandler=function(e,t){var n="scroll.select2."+t.id,i="resize.select2."+t.id,r="orientationchange.select2."+t.id;this.$container.parents().filter(a.hasScroll).off(n),f(window).off(n+" "+i+" "+r)},e.prototype._positionDropdown=function(){var e=f(window),t=this.$dropdown.hasClass("select2-dropdown--above"),n=this.$dropdown.hasClass("select2-dropdown--below"),i=null,r=this.$container.offset();r.bottom=r.top+this.$container.outerHeight(!1);var o={height:this.$container.outerHeight(!1)};o.top=r.top,o.bottom=r.top+o.height;var s=this.$dropdown.outerHeight(!1),a=e.scrollTop(),l=e.scrollTop()+e.height(),c=ar.bottom+s,d={left:r.left,top:o.bottom},p=this.$dropdownParent;"static"===p.css("position")&&(p=p.offsetParent());var h={top:0,left:0};(f.contains(document.body,p[0])||p[0].isConnected)&&(h=p.offset()),d.top-=h.top,d.left-=h.left,t||n||(i="below"),u||!c||t?!c&&u&&t&&(i="below"):i="above",("above"==i||t&&"below"!==i)&&(d.top=o.top-h.top-s),null!=i&&(this.$dropdown.removeClass("select2-dropdown--below select2-dropdown--above").addClass("select2-dropdown--"+i),this.$container.removeClass("select2-container--below select2-container--above").addClass("select2-container--"+i)),this.$dropdownContainer.css(d)},e.prototype._resizeDropdown=function(){var e={width:this.$container.outerWidth(!1)+"px"};this.options.get("dropdownAutoWidth")&&(e.minWidth=e.width,e.position="relative",e.width="auto"),this.$dropdown.css(e)},e.prototype._showDropdown=function(e){this.$dropdownContainer.appendTo(this.$dropdownParent),this._positionDropdown(),this._resizeDropdown()},e}),e.define("select2/dropdown/minimumResultsForSearch",[],function(){function e(e,t,n,i){this.minimumResultsForSearch=n.get("minimumResultsForSearch"),this.minimumResultsForSearch<0&&(this.minimumResultsForSearch=1/0),e.call(this,t,n,i)}return e.prototype.showSearch=function(e,t){return!(function e(t){for(var n=0,i=0;i');return e.attr("dir",this.options.get("dir")),this.$container=e,this.$container.addClass("select2-container--"+this.options.get("theme")),u.StoreData(e[0],"element",this.$element),e},d}),e.define("select2/compat/utils",["jquery"],function(s){return{syncCssClasses:function(e,t,n){var i,r,o=[];(i=s.trim(e.attr("class")))&&s((i=""+i).split(/\s+/)).each(function(){0===this.indexOf("select2-")&&o.push(this)}),(i=s.trim(t.attr("class")))&&s((i=""+i).split(/\s+/)).each(function(){0!==this.indexOf("select2-")&&null!=(r=n(this))&&o.push(r)}),e.attr("class",o.join(" "))}}}),e.define("select2/compat/containerCss",["jquery","./utils"],function(s,a){function l(e){return null}function e(){}return e.prototype.render=function(e){var t=e.call(this),n=this.options.get("containerCssClass")||"";s.isFunction(n)&&(n=n(this.$element));var i=this.options.get("adaptContainerCssClass");if(i=i||l,-1!==n.indexOf(":all:")){n=n.replace(":all:","");var r=i;i=function(e){var t=r(e);return null!=t?t+" "+e:e}}var o=this.options.get("containerCss")||{};return s.isFunction(o)&&(o=o(this.$element)),a.syncCssClasses(t,this.$element,i),t.css(o),t.addClass(n),t},e}),e.define("select2/compat/dropdownCss",["jquery","./utils"],function(s,a){function l(e){return null}function e(){}return e.prototype.render=function(e){var t=e.call(this),n=this.options.get("dropdownCssClass")||"";s.isFunction(n)&&(n=n(this.$element));var i=this.options.get("adaptDropdownCssClass");if(i=i||l,-1!==n.indexOf(":all:")){n=n.replace(":all:","");var r=i;i=function(e){var t=r(e);return null!=t?t+" "+e:e}}var o=this.options.get("dropdownCss")||{};return s.isFunction(o)&&(o=o(this.$element)),a.syncCssClasses(t,this.$element,i),t.css(o),t.addClass(n),t},e}),e.define("select2/compat/initSelection",["jquery"],function(i){function e(e,t,n){n.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=n.get("initSelection"),this._isInitialized=!1,e.call(this,t,n)}return e.prototype.current=function(e,t){var n=this;this._isInitialized?e.call(this,t):this.initSelection.call(null,this.$element,function(e){n._isInitialized=!0,i.isArray(e)||(e=[e]),t(e)})},e}),e.define("select2/compat/inputData",["jquery","../utils"],function(s,i){function e(e,t,n){this._currentData=[],this._valueSeparator=n.get("valueSeparator")||",","hidden"===t.prop("type")&&n.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=t,this.$search=t.find("input");var n=e.call(this);return this._transferTabIndex(),n},e.prototype.bind=function(e,t,n){var r=this,i=t.id+"-results";e.call(this,t,n),t.on("open",function(){r.$search.attr("aria-controls",i),r.$search.trigger("focus")}),t.on("close",function(){r.$search.val(""),r.$search.removeAttr("aria-controls"),r.$search.removeAttr("aria-activedescendant"),r.$search.trigger("focus")}),t.on("enable",function(){r.$search.prop("disabled",!1),r._transferTabIndex()}),t.on("disable",function(){r.$search.prop("disabled",!0)}),t.on("focus",function(e){r.$search.trigger("focus")}),t.on("results:focus",function(e){e.data._resultId?r.$search.attr("aria-activedescendant",e.data._resultId):r.$search.removeAttr("aria-activedescendant")}),this.$selection.on("focusin",".select2-search--inline",function(e){r.trigger("focus",e)}),this.$selection.on("focusout",".select2-search--inline",function(e){r._handleBlur(e)}),this.$selection.on("keydown",".select2-search--inline",function(e){if(e.stopPropagation(),r.trigger("keypress",e),r._keyUpPrevented=e.isDefaultPrevented(),e.which===l.BACKSPACE&&""===r.$search.val()){var t=r.$searchContainer.prev(".select2-selection__choice");if(0this.maximumInputLength?this.trigger("results:message",{message:"inputTooLong",args:{maximum:this.maximumInputLength,input:t.term,params:t}}):e.call(this,t,n)},e}),e.define("select2/data/maximumSelectionLength",[],function(){function e(e,t,n){this.maximumSelectionLength=n.get("maximumSelectionLength"),e.call(this,t,n)}return e.prototype.bind=function(e,t,n){var r=this;e.call(this,t,n),t.on("select",function(){r._checkIfMaximumSelected()})},e.prototype.query=function(e,t,n){var r=this;this._checkIfMaximumSelected(function(){e.call(r,t,n)})},e.prototype._checkIfMaximumSelected=function(e,n){var r=this;this.current(function(e){var t=null!=e?e.length:0;0=r.maximumSelectionLength?r.trigger("results:message",{message:"maximumSelected",args:{maximum:r.maximumSelectionLength}}):n&&n()})},e}),e.define("select2/dropdown",["jquery","./utils"],function(t,e){function n(e,t){this.$element=e,this.options=t,n.__super__.constructor.call(this)}return e.Extend(n,e.Observable),n.prototype.render=function(){var e=t('');return e.attr("dir",this.options.get("dir")),this.$dropdown=e},n.prototype.bind=function(){},n.prototype.position=function(e,t){},n.prototype.destroy=function(){this.$dropdown.remove()},n}),e.define("select2/dropdown/search",["jquery","../utils"],function(o,e){function t(){}return t.prototype.render=function(e){var t=e.call(this),n=o('');return this.$searchContainer=n,this.$search=n.find("input"),t.prepend(n),t},t.prototype.bind=function(e,t,n){var r=this,i=t.id+"-results";e.call(this,t,n),this.$search.on("keydown",function(e){r.trigger("keypress",e),r._keyUpPrevented=e.isDefaultPrevented()}),this.$search.on("input",function(e){o(this).off("keyup")}),this.$search.on("keyup input",function(e){r.handleSearch(e)}),t.on("open",function(){r.$search.attr("tabindex",0),r.$search.attr("aria-controls",i),r.$search.trigger("focus"),window.setTimeout(function(){r.$search.trigger("focus")},0)}),t.on("close",function(){r.$search.attr("tabindex",-1),r.$search.removeAttr("aria-controls"),r.$search.removeAttr("aria-activedescendant"),r.$search.val(""),r.$search.trigger("blur")}),t.on("focus",function(){t.isOpen()||r.$search.trigger("focus")}),t.on("results:all",function(e){null!=e.query.term&&""!==e.query.term||(r.showSearch(e)?r.$searchContainer.removeClass("select2-search--hide"):r.$searchContainer.addClass("select2-search--hide"))}),t.on("results:focus",function(e){e.data._resultId?r.$search.attr("aria-activedescendant",e.data._resultId):r.$search.removeAttr("aria-activedescendant")})},t.prototype.handleSearch=function(e){if(!this._keyUpPrevented){var t=this.$search.val();this.trigger("query",{term:t})}this._keyUpPrevented=!1},t.prototype.showSearch=function(e,t){return!0},t}),e.define("select2/dropdown/hidePlaceholder",[],function(){function e(e,t,n,r){this.placeholder=this.normalizePlaceholder(n.get("placeholder")),e.call(this,t,n,r)}return e.prototype.append=function(e,t){t.results=this.removePlaceholder(t.results),e.call(this,t)},e.prototype.normalizePlaceholder=function(e,t){return"string"==typeof t&&(t={id:"",text:t}),t},e.prototype.removePlaceholder=function(e,t){for(var n=t.slice(0),r=t.length-1;0<=r;r--){var i=t[r];this.placeholder.id===i.id&&n.splice(r,1)}return n},e}),e.define("select2/dropdown/infiniteScroll",["jquery"],function(n){function e(e,t,n,r){this.lastParams={},e.call(this,t,n,r),this.$loadingMore=this.createLoadingMore(),this.loading=!1}return e.prototype.append=function(e,t){this.$loadingMore.remove(),this.loading=!1,e.call(this,t),this.showLoadingMore(t)&&(this.$results.append(this.$loadingMore),this.loadMoreIfNeeded())},e.prototype.bind=function(e,t,n){var r=this;e.call(this,t,n),t.on("query",function(e){r.lastParams=e,r.loading=!0}),t.on("query:append",function(e){r.lastParams=e,r.loading=!0}),this.$results.on("scroll",this.loadMoreIfNeeded.bind(this))},e.prototype.loadMoreIfNeeded=function(){var e=n.contains(document.documentElement,this.$loadingMore[0]);if(!this.loading&&e){var t=this.$results.offset().top+this.$results.outerHeight(!1);this.$loadingMore.offset().top+this.$loadingMore.outerHeight(!1)<=t+50&&this.loadMore()}},e.prototype.loadMore=function(){this.loading=!0;var e=n.extend({},{page:1},this.lastParams);e.page++,this.trigger("query:append",e)},e.prototype.showLoadingMore=function(e,t){return t.pagination&&t.pagination.more},e.prototype.createLoadingMore=function(){var e=n('
  • '),t=this.options.get("translations").get("loadingMore");return e.html(t(this.lastParams)),e},e}),e.define("select2/dropdown/attachBody",["jquery","../utils"],function(f,a){function e(e,t,n){this.$dropdownParent=f(n.get("dropdownParent")||document.body),e.call(this,t,n)}return e.prototype.bind=function(e,t,n){var r=this;e.call(this,t,n),t.on("open",function(){r._showDropdown(),r._attachPositioningHandler(t),r._bindContainerResultHandlers(t)}),t.on("close",function(){r._hideDropdown(),r._detachPositioningHandler(t)}),this.$dropdownContainer.on("mousedown",function(e){e.stopPropagation()})},e.prototype.destroy=function(e){e.call(this),this.$dropdownContainer.remove()},e.prototype.position=function(e,t,n){t.attr("class",n.attr("class")),t.removeClass("select2"),t.addClass("select2-container--open"),t.css({position:"absolute",top:-999999}),this.$container=n},e.prototype.render=function(e){var t=f(""),n=e.call(this);return t.append(n),this.$dropdownContainer=t},e.prototype._hideDropdown=function(e){this.$dropdownContainer.detach()},e.prototype._bindContainerResultHandlers=function(e,t){if(!this._containerResultsHandlersBound){var n=this;t.on("results:all",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("results:append",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("results:message",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("select",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("unselect",function(){n._positionDropdown(),n._resizeDropdown()}),this._containerResultsHandlersBound=!0}},e.prototype._attachPositioningHandler=function(e,t){var n=this,r="scroll.select2."+t.id,i="resize.select2."+t.id,o="orientationchange.select2."+t.id,s=this.$container.parents().filter(a.hasScroll);s.each(function(){a.StoreData(this,"select2-scroll-position",{x:f(this).scrollLeft(),y:f(this).scrollTop()})}),s.on(r,function(e){var t=a.GetData(this,"select2-scroll-position");f(this).scrollTop(t.y)}),f(window).on(r+" "+i+" "+o,function(e){n._positionDropdown(),n._resizeDropdown()})},e.prototype._detachPositioningHandler=function(e,t){var n="scroll.select2."+t.id,r="resize.select2."+t.id,i="orientationchange.select2."+t.id;this.$container.parents().filter(a.hasScroll).off(n),f(window).off(n+" "+r+" "+i)},e.prototype._positionDropdown=function(){var e=f(window),t=this.$dropdown.hasClass("select2-dropdown--above"),n=this.$dropdown.hasClass("select2-dropdown--below"),r=null,i=this.$container.offset();i.bottom=i.top+this.$container.outerHeight(!1);var o={height:this.$container.outerHeight(!1)};o.top=i.top,o.bottom=i.top+o.height;var s=this.$dropdown.outerHeight(!1),a=e.scrollTop(),l=e.scrollTop()+e.height(),c=ai.bottom+s,d={left:i.left,top:o.bottom},p=this.$dropdownParent;"static"===p.css("position")&&(p=p.offsetParent());var h={top:0,left:0};(f.contains(document.body,p[0])||p[0].isConnected)&&(h=p.offset()),d.top-=h.top,d.left-=h.left,t||n||(r="below"),u||!c||t?!c&&u&&t&&(r="below"):r="above",("above"==r||t&&"below"!==r)&&(d.top=o.top-h.top-s),null!=r&&(this.$dropdown.removeClass("select2-dropdown--below select2-dropdown--above").addClass("select2-dropdown--"+r),this.$container.removeClass("select2-container--below select2-container--above").addClass("select2-container--"+r)),this.$dropdownContainer.css(d)},e.prototype._resizeDropdown=function(){var e={width:this.$container.outerWidth(!1)+"px"};this.options.get("dropdownAutoWidth")&&(e.minWidth=e.width,e.position="relative",e.width="auto"),this.$dropdown.css(e)},e.prototype._showDropdown=function(e){this.$dropdownContainer.appendTo(this.$dropdownParent),this._positionDropdown(),this._resizeDropdown()},e}),e.define("select2/dropdown/minimumResultsForSearch",[],function(){function e(e,t,n,r){this.minimumResultsForSearch=n.get("minimumResultsForSearch"),this.minimumResultsForSearch<0&&(this.minimumResultsForSearch=1/0),e.call(this,t,n,r)}return e.prototype.showSearch=function(e,t){return!(function e(t){for(var n=0,r=0;r');return e.attr("dir",this.options.get("dir")),this.$container=e,this.$container.addClass("select2-container--"+this.options.get("theme")),u.StoreData(e[0],"element",this.$element),e},d}),e.define("jquery-mousewheel",["jquery"],function(e){return e}),e.define("jquery.select2",["jquery","jquery-mousewheel","./select2/core","./select2/defaults","./select2/utils"],function(i,e,o,t,s){if(null==i.fn.select2){var a=["open","close","destroy"];i.fn.select2=function(t){if("object"==typeof(t=t||{}))return this.each(function(){var e=i.extend(!0,{},t);new o(i(this),e)}),this;if("string"!=typeof t)throw new Error("Invalid arguments for Select2: "+t);var n,r=Array.prototype.slice.call(arguments,1);return this.each(function(){var e=s.GetData(this,"select2");null==e&&window.console&&console.error&&console.error("The select2('"+t+"') method was called on an element that is not using Select2."),n=e[t].apply(e,r)}),-1li{position:relative}.fa-li{position:absolute;left:-2.14285714em;width:2.14285714em;top:.14285714em;text-align:center}.fa-li.fa-lg{left:-1.85714286em}.fa-border{padding:.2em .25em .15em;border:solid .08em #eee;border-radius:.1em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left{margin-right:.3em}.fa.fa-pull-right{margin-left:.3em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left{margin-right:.3em}.fa.pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}.fa-pulse{-webkit-animation:fa-spin 1s infinite steps(8);animation:fa-spin 1s infinite steps(8)}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=1)";-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2)";-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=3)";-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)";-webkit-transform:scale(-1, 1);-ms-transform:scale(-1, 1);transform:scale(-1, 1)}.fa-flip-vertical{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)";-webkit-transform:scale(1, -1);-ms-transform:scale(1, -1);transform:scale(1, -1)}:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-flip-horizontal,:root .fa-flip-vertical{filter:none}.fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:"\f000"}.fa-music:before{content:"\f001"}.fa-search:before{content:"\f002"}.fa-envelope-o:before{content:"\f003"}.fa-heart:before{content:"\f004"}.fa-star:before{content:"\f005"}.fa-star-o:before{content:"\f006"}.fa-user:before{content:"\f007"}.fa-film:before{content:"\f008"}.fa-th-large:before{content:"\f009"}.fa-th:before{content:"\f00a"}.fa-th-list:before{content:"\f00b"}.fa-check:before{content:"\f00c"}.fa-remove:before,.fa-close:before,.fa-times:before{content:"\f00d"}.fa-search-plus:before{content:"\f00e"}.fa-search-minus:before{content:"\f010"}.fa-power-off:before{content:"\f011"}.fa-signal:before{content:"\f012"}.fa-gear:before,.fa-cog:before{content:"\f013"}.fa-trash-o:before{content:"\f014"}.fa-home:before{content:"\f015"}.fa-file-o:before{content:"\f016"}.fa-clock-o:before{content:"\f017"}.fa-road:before{content:"\f018"}.fa-download:before{content:"\f019"}.fa-arrow-circle-o-down:before{content:"\f01a"}.fa-arrow-circle-o-up:before{content:"\f01b"}.fa-inbox:before{content:"\f01c"}.fa-play-circle-o:before{content:"\f01d"}.fa-rotate-right:before,.fa-repeat:before{content:"\f01e"}.fa-refresh:before{content:"\f021"}.fa-list-alt:before{content:"\f022"}.fa-lock:before{content:"\f023"}.fa-flag:before{content:"\f024"}.fa-headphones:before{content:"\f025"}.fa-volume-off:before{content:"\f026"}.fa-volume-down:before{content:"\f027"}.fa-volume-up:before{content:"\f028"}.fa-qrcode:before{content:"\f029"}.fa-barcode:before{content:"\f02a"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-book:before{content:"\f02d"}.fa-bookmark:before{content:"\f02e"}.fa-print:before{content:"\f02f"}.fa-camera:before{content:"\f030"}.fa-font:before{content:"\f031"}.fa-bold:before{content:"\f032"}.fa-italic:before{content:"\f033"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-align-left:before{content:"\f036"}.fa-align-center:before{content:"\f037"}.fa-align-right:before{content:"\f038"}.fa-align-justify:before{content:"\f039"}.fa-list:before{content:"\f03a"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-indent:before{content:"\f03c"}.fa-video-camera:before{content:"\f03d"}.fa-photo:before,.fa-image:before,.fa-picture-o:before{content:"\f03e"}.fa-pencil:before{content:"\f040"}.fa-map-marker:before{content:"\f041"}.fa-adjust:before{content:"\f042"}.fa-tint:before{content:"\f043"}.fa-edit:before,.fa-pencil-square-o:before{content:"\f044"}.fa-share-square-o:before{content:"\f045"}.fa-check-square-o:before{content:"\f046"}.fa-arrows:before{content:"\f047"}.fa-step-backward:before{content:"\f048"}.fa-fast-backward:before{content:"\f049"}.fa-backward:before{content:"\f04a"}.fa-play:before{content:"\f04b"}.fa-pause:before{content:"\f04c"}.fa-stop:before{content:"\f04d"}.fa-forward:before{content:"\f04e"}.fa-fast-forward:before{content:"\f050"}.fa-step-forward:before{content:"\f051"}.fa-eject:before{content:"\f052"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-plus-circle:before{content:"\f055"}.fa-minus-circle:before{content:"\f056"}.fa-times-circle:before{content:"\f057"}.fa-check-circle:before{content:"\f058"}.fa-question-circle:before{content:"\f059"}.fa-info-circle:before{content:"\f05a"}.fa-crosshairs:before{content:"\f05b"}.fa-times-circle-o:before{content:"\f05c"}.fa-check-circle-o:before{content:"\f05d"}.fa-ban:before{content:"\f05e"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrow-down:before{content:"\f063"}.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-expand:before{content:"\f065"}.fa-compress:before{content:"\f066"}.fa-plus:before{content:"\f067"}.fa-minus:before{content:"\f068"}.fa-asterisk:before{content:"\f069"}.fa-exclamation-circle:before{content:"\f06a"}.fa-gift:before{content:"\f06b"}.fa-leaf:before{content:"\f06c"}.fa-fire:before{content:"\f06d"}.fa-eye:before{content:"\f06e"}.fa-eye-slash:before{content:"\f070"}.fa-warning:before,.fa-exclamation-triangle:before{content:"\f071"}.fa-plane:before{content:"\f072"}.fa-calendar:before{content:"\f073"}.fa-random:before{content:"\f074"}.fa-comment:before{content:"\f075"}.fa-magnet:before{content:"\f076"}.fa-chevron-up:before{content:"\f077"}.fa-chevron-down:before{content:"\f078"}.fa-retweet:before{content:"\f079"}.fa-shopping-cart:before{content:"\f07a"}.fa-folder:before{content:"\f07b"}.fa-folder-open:before{content:"\f07c"}.fa-arrows-v:before{content:"\f07d"}.fa-arrows-h:before{content:"\f07e"}.fa-bar-chart-o:before,.fa-bar-chart:before{content:"\f080"}.fa-twitter-square:before{content:"\f081"}.fa-facebook-square:before{content:"\f082"}.fa-camera-retro:before{content:"\f083"}.fa-key:before{content:"\f084"}.fa-gears:before,.fa-cogs:before{content:"\f085"}.fa-comments:before{content:"\f086"}.fa-thumbs-o-up:before{content:"\f087"}.fa-thumbs-o-down:before{content:"\f088"}.fa-star-half:before{content:"\f089"}.fa-heart-o:before{content:"\f08a"}.fa-sign-out:before{content:"\f08b"}.fa-linkedin-square:before{content:"\f08c"}.fa-thumb-tack:before{content:"\f08d"}.fa-external-link:before{content:"\f08e"}.fa-sign-in:before{content:"\f090"}.fa-trophy:before{content:"\f091"}.fa-github-square:before{content:"\f092"}.fa-upload:before{content:"\f093"}.fa-lemon-o:before{content:"\f094"}.fa-phone:before{content:"\f095"}.fa-square-o:before{content:"\f096"}.fa-bookmark-o:before{content:"\f097"}.fa-phone-square:before{content:"\f098"}.fa-twitter:before{content:"\f099"}.fa-facebook-f:before,.fa-facebook:before{content:"\f09a"}.fa-github:before{content:"\f09b"}.fa-unlock:before{content:"\f09c"}.fa-credit-card:before{content:"\f09d"}.fa-feed:before,.fa-rss:before{content:"\f09e"}.fa-hdd-o:before{content:"\f0a0"}.fa-bullhorn:before{content:"\f0a1"}.fa-bell:before{content:"\f0f3"}.fa-certificate:before{content:"\f0a3"}.fa-hand-o-right:before{content:"\f0a4"}.fa-hand-o-left:before{content:"\f0a5"}.fa-hand-o-up:before{content:"\f0a6"}.fa-hand-o-down:before{content:"\f0a7"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-globe:before{content:"\f0ac"}.fa-wrench:before{content:"\f0ad"}.fa-tasks:before{content:"\f0ae"}.fa-filter:before{content:"\f0b0"}.fa-briefcase:before{content:"\f0b1"}.fa-arrows-alt:before{content:"\f0b2"}.fa-group:before,.fa-users:before{content:"\f0c0"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-cloud:before{content:"\f0c2"}.fa-flask:before{content:"\f0c3"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-copy:before,.fa-files-o:before{content:"\f0c5"}.fa-paperclip:before{content:"\f0c6"}.fa-save:before,.fa-floppy-o:before{content:"\f0c7"}.fa-square:before{content:"\f0c8"}.fa-navicon:before,.fa-reorder:before,.fa-bars:before{content:"\f0c9"}.fa-list-ul:before{content:"\f0ca"}.fa-list-ol:before{content:"\f0cb"}.fa-strikethrough:before{content:"\f0cc"}.fa-underline:before{content:"\f0cd"}.fa-table:before{content:"\f0ce"}.fa-magic:before{content:"\f0d0"}.fa-truck:before{content:"\f0d1"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-square:before{content:"\f0d3"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-plus:before{content:"\f0d5"}.fa-money:before{content:"\f0d6"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-up:before{content:"\f0d8"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-columns:before{content:"\f0db"}.fa-unsorted:before,.fa-sort:before{content:"\f0dc"}.fa-sort-down:before,.fa-sort-desc:before{content:"\f0dd"}.fa-sort-up:before,.fa-sort-asc:before{content:"\f0de"}.fa-envelope:before{content:"\f0e0"}.fa-linkedin:before{content:"\f0e1"}.fa-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-legal:before,.fa-gavel:before{content:"\f0e3"}.fa-dashboard:before,.fa-tachometer:before{content:"\f0e4"}.fa-comment-o:before{content:"\f0e5"}.fa-comments-o:before{content:"\f0e6"}.fa-flash:before,.fa-bolt:before{content:"\f0e7"}.fa-sitemap:before{content:"\f0e8"}.fa-umbrella:before{content:"\f0e9"}.fa-paste:before,.fa-clipboard:before{content:"\f0ea"}.fa-lightbulb-o:before{content:"\f0eb"}.fa-exchange:before{content:"\f0ec"}.fa-cloud-download:before{content:"\f0ed"}.fa-cloud-upload:before{content:"\f0ee"}.fa-user-md:before{content:"\f0f0"}.fa-stethoscope:before{content:"\f0f1"}.fa-suitcase:before{content:"\f0f2"}.fa-bell-o:before{content:"\f0a2"}.fa-coffee:before{content:"\f0f4"}.fa-cutlery:before{content:"\f0f5"}.fa-file-text-o:before{content:"\f0f6"}.fa-building-o:before{content:"\f0f7"}.fa-hospital-o:before{content:"\f0f8"}.fa-ambulance:before{content:"\f0f9"}.fa-medkit:before{content:"\f0fa"}.fa-fighter-jet:before{content:"\f0fb"}.fa-beer:before{content:"\f0fc"}.fa-h-square:before{content:"\f0fd"}.fa-plus-square:before{content:"\f0fe"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angle-down:before{content:"\f107"}.fa-desktop:before{content:"\f108"}.fa-laptop:before{content:"\f109"}.fa-tablet:before{content:"\f10a"}.fa-mobile-phone:before,.fa-mobile:before{content:"\f10b"}.fa-circle-o:before{content:"\f10c"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-spinner:before{content:"\f110"}.fa-circle:before{content:"\f111"}.fa-mail-reply:before,.fa-reply:before{content:"\f112"}.fa-github-alt:before{content:"\f113"}.fa-folder-o:before{content:"\f114"}.fa-folder-open-o:before{content:"\f115"}.fa-smile-o:before{content:"\f118"}.fa-frown-o:before{content:"\f119"}.fa-meh-o:before{content:"\f11a"}.fa-gamepad:before{content:"\f11b"}.fa-keyboard-o:before{content:"\f11c"}.fa-flag-o:before{content:"\f11d"}.fa-flag-checkered:before{content:"\f11e"}.fa-terminal:before{content:"\f120"}.fa-code:before{content:"\f121"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:"\f123"}.fa-location-arrow:before{content:"\f124"}.fa-crop:before{content:"\f125"}.fa-code-fork:before{content:"\f126"}.fa-unlink:before,.fa-chain-broken:before{content:"\f127"}.fa-question:before{content:"\f128"}.fa-info:before{content:"\f129"}.fa-exclamation:before{content:"\f12a"}.fa-superscript:before{content:"\f12b"}.fa-subscript:before{content:"\f12c"}.fa-eraser:before{content:"\f12d"}.fa-puzzle-piece:before{content:"\f12e"}.fa-microphone:before{content:"\f130"}.fa-microphone-slash:before{content:"\f131"}.fa-shield:before{content:"\f132"}.fa-calendar-o:before{content:"\f133"}.fa-fire-extinguisher:before{content:"\f134"}.fa-rocket:before{content:"\f135"}.fa-maxcdn:before{content:"\f136"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-html5:before{content:"\f13b"}.fa-css3:before{content:"\f13c"}.fa-anchor:before{content:"\f13d"}.fa-unlock-alt:before{content:"\f13e"}.fa-bullseye:before{content:"\f140"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-rss-square:before{content:"\f143"}.fa-play-circle:before{content:"\f144"}.fa-ticket:before{content:"\f145"}.fa-minus-square:before{content:"\f146"}.fa-minus-square-o:before{content:"\f147"}.fa-level-up:before{content:"\f148"}.fa-level-down:before{content:"\f149"}.fa-check-square:before{content:"\f14a"}.fa-pencil-square:before{content:"\f14b"}.fa-external-link-square:before{content:"\f14c"}.fa-share-square:before{content:"\f14d"}.fa-compass:before{content:"\f14e"}.fa-toggle-down:before,.fa-caret-square-o-down:before{content:"\f150"}.fa-toggle-up:before,.fa-caret-square-o-up:before{content:"\f151"}.fa-toggle-right:before,.fa-caret-square-o-right:before{content:"\f152"}.fa-euro:before,.fa-eur:before{content:"\f153"}.fa-gbp:before{content:"\f154"}.fa-dollar:before,.fa-usd:before{content:"\f155"}.fa-rupee:before,.fa-inr:before{content:"\f156"}.fa-cny:before,.fa-rmb:before,.fa-yen:before,.fa-jpy:before{content:"\f157"}.fa-ruble:before,.fa-rouble:before,.fa-rub:before{content:"\f158"}.fa-won:before,.fa-krw:before{content:"\f159"}.fa-bitcoin:before,.fa-btc:before{content:"\f15a"}.fa-file:before{content:"\f15b"}.fa-file-text:before{content:"\f15c"}.fa-sort-alpha-asc:before{content:"\f15d"}.fa-sort-alpha-desc:before{content:"\f15e"}.fa-sort-amount-asc:before{content:"\f160"}.fa-sort-amount-desc:before{content:"\f161"}.fa-sort-numeric-asc:before{content:"\f162"}.fa-sort-numeric-desc:before{content:"\f163"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbs-down:before{content:"\f165"}.fa-youtube-square:before{content:"\f166"}.fa-youtube:before{content:"\f167"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-youtube-play:before{content:"\f16a"}.fa-dropbox:before{content:"\f16b"}.fa-stack-overflow:before{content:"\f16c"}.fa-instagram:before{content:"\f16d"}.fa-flickr:before{content:"\f16e"}.fa-adn:before{content:"\f170"}.fa-bitbucket:before{content:"\f171"}.fa-bitbucket-square:before{content:"\f172"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-long-arrow-down:before{content:"\f175"}.fa-long-arrow-up:before{content:"\f176"}.fa-long-arrow-left:before{content:"\f177"}.fa-long-arrow-right:before{content:"\f178"}.fa-apple:before{content:"\f179"}.fa-windows:before{content:"\f17a"}.fa-android:before{content:"\f17b"}.fa-linux:before{content:"\f17c"}.fa-dribbble:before{content:"\f17d"}.fa-skype:before{content:"\f17e"}.fa-foursquare:before{content:"\f180"}.fa-trello:before{content:"\f181"}.fa-female:before{content:"\f182"}.fa-male:before{content:"\f183"}.fa-gittip:before,.fa-gratipay:before{content:"\f184"}.fa-sun-o:before{content:"\f185"}.fa-moon-o:before{content:"\f186"}.fa-archive:before{content:"\f187"}.fa-bug:before{content:"\f188"}.fa-vk:before{content:"\f189"}.fa-weibo:before{content:"\f18a"}.fa-renren:before{content:"\f18b"}.fa-pagelines:before{content:"\f18c"}.fa-stack-exchange:before{content:"\f18d"}.fa-arrow-circle-o-right:before{content:"\f18e"}.fa-arrow-circle-o-left:before{content:"\f190"}.fa-toggle-left:before,.fa-caret-square-o-left:before{content:"\f191"}.fa-dot-circle-o:before{content:"\f192"}.fa-wheelchair:before{content:"\f193"}.fa-vimeo-square:before{content:"\f194"}.fa-turkish-lira:before,.fa-try:before{content:"\f195"}.fa-plus-square-o:before{content:"\f196"}.fa-space-shuttle:before{content:"\f197"}.fa-slack:before{content:"\f198"}.fa-envelope-square:before{content:"\f199"}.fa-wordpress:before{content:"\f19a"}.fa-openid:before{content:"\f19b"}.fa-institution:before,.fa-bank:before,.fa-university:before{content:"\f19c"}.fa-mortar-board:before,.fa-graduation-cap:before{content:"\f19d"}.fa-yahoo:before{content:"\f19e"}.fa-google:before{content:"\f1a0"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-square:before{content:"\f1a2"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-stumbleupon:before{content:"\f1a4"}.fa-delicious:before{content:"\f1a5"}.fa-digg:before{content:"\f1a6"}.fa-pied-piper-pp:before{content:"\f1a7"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-drupal:before{content:"\f1a9"}.fa-joomla:before{content:"\f1aa"}.fa-language:before{content:"\f1ab"}.fa-fax:before{content:"\f1ac"}.fa-building:before{content:"\f1ad"}.fa-child:before{content:"\f1ae"}.fa-paw:before{content:"\f1b0"}.fa-spoon:before{content:"\f1b1"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-recycle:before{content:"\f1b8"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-tree:before{content:"\f1bb"}.fa-spotify:before{content:"\f1bc"}.fa-deviantart:before{content:"\f1bd"}.fa-soundcloud:before{content:"\f1be"}.fa-database:before{content:"\f1c0"}.fa-file-pdf-o:before{content:"\f1c1"}.fa-file-word-o:before{content:"\f1c2"}.fa-file-excel-o:before{content:"\f1c3"}.fa-file-powerpoint-o:before{content:"\f1c4"}.fa-file-photo-o:before,.fa-file-picture-o:before,.fa-file-image-o:before{content:"\f1c5"}.fa-file-zip-o:before,.fa-file-archive-o:before{content:"\f1c6"}.fa-file-sound-o:before,.fa-file-audio-o:before{content:"\f1c7"}.fa-file-movie-o:before,.fa-file-video-o:before{content:"\f1c8"}.fa-file-code-o:before{content:"\f1c9"}.fa-vine:before{content:"\f1ca"}.fa-codepen:before{content:"\f1cb"}.fa-jsfiddle:before{content:"\f1cc"}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-saver:before,.fa-support:before,.fa-life-ring:before{content:"\f1cd"}.fa-circle-o-notch:before{content:"\f1ce"}.fa-ra:before,.fa-resistance:before,.fa-rebel:before{content:"\f1d0"}.fa-ge:before,.fa-empire:before{content:"\f1d1"}.fa-git-square:before{content:"\f1d2"}.fa-git:before{content:"\f1d3"}.fa-y-combinator-square:before,.fa-yc-square:before,.fa-hacker-news:before{content:"\f1d4"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-qq:before{content:"\f1d6"}.fa-wechat:before,.fa-weixin:before{content:"\f1d7"}.fa-send:before,.fa-paper-plane:before{content:"\f1d8"}.fa-send-o:before,.fa-paper-plane-o:before{content:"\f1d9"}.fa-history:before{content:"\f1da"}.fa-circle-thin:before{content:"\f1db"}.fa-header:before{content:"\f1dc"}.fa-paragraph:before{content:"\f1dd"}.fa-sliders:before{content:"\f1de"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-bomb:before{content:"\f1e2"}.fa-soccer-ball-o:before,.fa-futbol-o:before{content:"\f1e3"}.fa-tty:before{content:"\f1e4"}.fa-binoculars:before{content:"\f1e5"}.fa-plug:before{content:"\f1e6"}.fa-slideshare:before{content:"\f1e7"}.fa-twitch:before{content:"\f1e8"}.fa-yelp:before{content:"\f1e9"}.fa-newspaper-o:before{content:"\f1ea"}.fa-wifi:before{content:"\f1eb"}.fa-calculator:before{content:"\f1ec"}.fa-paypal:before{content:"\f1ed"}.fa-google-wallet:before{content:"\f1ee"}.fa-cc-visa:before{content:"\f1f0"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-bell-slash:before{content:"\f1f6"}.fa-bell-slash-o:before{content:"\f1f7"}.fa-trash:before{content:"\f1f8"}.fa-copyright:before{content:"\f1f9"}.fa-at:before{content:"\f1fa"}.fa-eyedropper:before{content:"\f1fb"}.fa-paint-brush:before{content:"\f1fc"}.fa-birthday-cake:before{content:"\f1fd"}.fa-area-chart:before{content:"\f1fe"}.fa-pie-chart:before{content:"\f200"}.fa-line-chart:before{content:"\f201"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-bicycle:before{content:"\f206"}.fa-bus:before{content:"\f207"}.fa-ioxhost:before{content:"\f208"}.fa-angellist:before{content:"\f209"}.fa-cc:before{content:"\f20a"}.fa-shekel:before,.fa-sheqel:before,.fa-ils:before{content:"\f20b"}.fa-meanpath:before{content:"\f20c"}.fa-buysellads:before{content:"\f20d"}.fa-connectdevelop:before{content:"\f20e"}.fa-dashcube:before{content:"\f210"}.fa-forumbee:before{content:"\f211"}.fa-leanpub:before{content:"\f212"}.fa-sellsy:before{content:"\f213"}.fa-shirtsinbulk:before{content:"\f214"}.fa-simplybuilt:before{content:"\f215"}.fa-skyatlas:before{content:"\f216"}.fa-cart-plus:before{content:"\f217"}.fa-cart-arrow-down:before{content:"\f218"}.fa-diamond:before{content:"\f219"}.fa-ship:before{content:"\f21a"}.fa-user-secret:before{content:"\f21b"}.fa-motorcycle:before{content:"\f21c"}.fa-street-view:before{content:"\f21d"}.fa-heartbeat:before{content:"\f21e"}.fa-venus:before{content:"\f221"}.fa-mars:before{content:"\f222"}.fa-mercury:before{content:"\f223"}.fa-intersex:before,.fa-transgender:before{content:"\f224"}.fa-transgender-alt:before{content:"\f225"}.fa-venus-double:before{content:"\f226"}.fa-mars-double:before{content:"\f227"}.fa-venus-mars:before{content:"\f228"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-v:before{content:"\f22a"}.fa-mars-stroke-h:before{content:"\f22b"}.fa-neuter:before{content:"\f22c"}.fa-genderless:before{content:"\f22d"}.fa-facebook-official:before{content:"\f230"}.fa-pinterest-p:before{content:"\f231"}.fa-whatsapp:before{content:"\f232"}.fa-server:before{content:"\f233"}.fa-user-plus:before{content:"\f234"}.fa-user-times:before{content:"\f235"}.fa-hotel:before,.fa-bed:before{content:"\f236"}.fa-viacoin:before{content:"\f237"}.fa-train:before{content:"\f238"}.fa-subway:before{content:"\f239"}.fa-medium:before{content:"\f23a"}.fa-yc:before,.fa-y-combinator:before{content:"\f23b"}.fa-optin-monster:before{content:"\f23c"}.fa-opencart:before{content:"\f23d"}.fa-expeditedssl:before{content:"\f23e"}.fa-battery-4:before,.fa-battery:before,.fa-battery-full:before{content:"\f240"}.fa-battery-3:before,.fa-battery-three-quarters:before{content:"\f241"}.fa-battery-2:before,.fa-battery-half:before{content:"\f242"}.fa-battery-1:before,.fa-battery-quarter:before{content:"\f243"}.fa-battery-0:before,.fa-battery-empty:before{content:"\f244"}.fa-mouse-pointer:before{content:"\f245"}.fa-i-cursor:before{content:"\f246"}.fa-object-group:before{content:"\f247"}.fa-object-ungroup:before{content:"\f248"}.fa-sticky-note:before{content:"\f249"}.fa-sticky-note-o:before{content:"\f24a"}.fa-cc-jcb:before{content:"\f24b"}.fa-cc-diners-club:before{content:"\f24c"}.fa-clone:before{content:"\f24d"}.fa-balance-scale:before{content:"\f24e"}.fa-hourglass-o:before{content:"\f250"}.fa-hourglass-1:before,.fa-hourglass-start:before{content:"\f251"}.fa-hourglass-2:before,.fa-hourglass-half:before{content:"\f252"}.fa-hourglass-3:before,.fa-hourglass-end:before{content:"\f253"}.fa-hourglass:before{content:"\f254"}.fa-hand-grab-o:before,.fa-hand-rock-o:before{content:"\f255"}.fa-hand-stop-o:before,.fa-hand-paper-o:before{content:"\f256"}.fa-hand-scissors-o:before{content:"\f257"}.fa-hand-lizard-o:before{content:"\f258"}.fa-hand-spock-o:before{content:"\f259"}.fa-hand-pointer-o:before{content:"\f25a"}.fa-hand-peace-o:before{content:"\f25b"}.fa-trademark:before{content:"\f25c"}.fa-registered:before{content:"\f25d"}.fa-creative-commons:before{content:"\f25e"}.fa-gg:before{content:"\f260"}.fa-gg-circle:before{content:"\f261"}.fa-tripadvisor:before{content:"\f262"}.fa-odnoklassniki:before{content:"\f263"}.fa-odnoklassniki-square:before{content:"\f264"}.fa-get-pocket:before{content:"\f265"}.fa-wikipedia-w:before{content:"\f266"}.fa-safari:before{content:"\f267"}.fa-chrome:before{content:"\f268"}.fa-firefox:before{content:"\f269"}.fa-opera:before{content:"\f26a"}.fa-internet-explorer:before{content:"\f26b"}.fa-tv:before,.fa-television:before{content:"\f26c"}.fa-contao:before{content:"\f26d"}.fa-500px:before{content:"\f26e"}.fa-amazon:before{content:"\f270"}.fa-calendar-plus-o:before{content:"\f271"}.fa-calendar-minus-o:before{content:"\f272"}.fa-calendar-times-o:before{content:"\f273"}.fa-calendar-check-o:before{content:"\f274"}.fa-industry:before{content:"\f275"}.fa-map-pin:before{content:"\f276"}.fa-map-signs:before{content:"\f277"}.fa-map-o:before{content:"\f278"}.fa-map:before{content:"\f279"}.fa-commenting:before{content:"\f27a"}.fa-commenting-o:before{content:"\f27b"}.fa-houzz:before{content:"\f27c"}.fa-vimeo:before{content:"\f27d"}.fa-black-tie:before{content:"\f27e"}.fa-fonticons:before{content:"\f280"}.fa-reddit-alien:before{content:"\f281"}.fa-edge:before{content:"\f282"}.fa-credit-card-alt:before{content:"\f283"}.fa-codiepie:before{content:"\f284"}.fa-modx:before{content:"\f285"}.fa-fort-awesome:before{content:"\f286"}.fa-usb:before{content:"\f287"}.fa-product-hunt:before{content:"\f288"}.fa-mixcloud:before{content:"\f289"}.fa-scribd:before{content:"\f28a"}.fa-pause-circle:before{content:"\f28b"}.fa-pause-circle-o:before{content:"\f28c"}.fa-stop-circle:before{content:"\f28d"}.fa-stop-circle-o:before{content:"\f28e"}.fa-shopping-bag:before{content:"\f290"}.fa-shopping-basket:before{content:"\f291"}.fa-hashtag:before{content:"\f292"}.fa-bluetooth:before{content:"\f293"}.fa-bluetooth-b:before{content:"\f294"}.fa-percent:before{content:"\f295"}.fa-gitlab:before{content:"\f296"}.fa-wpbeginner:before{content:"\f297"}.fa-wpforms:before{content:"\f298"}.fa-envira:before{content:"\f299"}.fa-universal-access:before{content:"\f29a"}.fa-wheelchair-alt:before{content:"\f29b"}.fa-question-circle-o:before{content:"\f29c"}.fa-blind:before{content:"\f29d"}.fa-audio-description:before{content:"\f29e"}.fa-volume-control-phone:before{content:"\f2a0"}.fa-braille:before{content:"\f2a1"}.fa-assistive-listening-systems:before{content:"\f2a2"}.fa-asl-interpreting:before,.fa-american-sign-language-interpreting:before{content:"\f2a3"}.fa-deafness:before,.fa-hard-of-hearing:before,.fa-deaf:before{content:"\f2a4"}.fa-glide:before{content:"\f2a5"}.fa-glide-g:before{content:"\f2a6"}.fa-signing:before,.fa-sign-language:before{content:"\f2a7"}.fa-low-vision:before{content:"\f2a8"}.fa-viadeo:before{content:"\f2a9"}.fa-viadeo-square:before{content:"\f2aa"}.fa-snapchat:before{content:"\f2ab"}.fa-snapchat-ghost:before{content:"\f2ac"}.fa-snapchat-square:before{content:"\f2ad"}.fa-pied-piper:before{content:"\f2ae"}.fa-first-order:before{content:"\f2b0"}.fa-yoast:before{content:"\f2b1"}.fa-themeisle:before{content:"\f2b2"}.fa-google-plus-circle:before,.fa-google-plus-official:before{content:"\f2b3"}.fa-fa:before,.fa-font-awesome:before{content:"\f2b4"}.fa-handshake-o:before{content:"\f2b5"}.fa-envelope-open:before{content:"\f2b6"}.fa-envelope-open-o:before{content:"\f2b7"}.fa-linode:before{content:"\f2b8"}.fa-address-book:before{content:"\f2b9"}.fa-address-book-o:before{content:"\f2ba"}.fa-vcard:before,.fa-address-card:before{content:"\f2bb"}.fa-vcard-o:before,.fa-address-card-o:before{content:"\f2bc"}.fa-user-circle:before{content:"\f2bd"}.fa-user-circle-o:before{content:"\f2be"}.fa-user-o:before{content:"\f2c0"}.fa-id-badge:before{content:"\f2c1"}.fa-drivers-license:before,.fa-id-card:before{content:"\f2c2"}.fa-drivers-license-o:before,.fa-id-card-o:before{content:"\f2c3"}.fa-quora:before{content:"\f2c4"}.fa-free-code-camp:before{content:"\f2c5"}.fa-telegram:before{content:"\f2c6"}.fa-thermometer-4:before,.fa-thermometer:before,.fa-thermometer-full:before{content:"\f2c7"}.fa-thermometer-3:before,.fa-thermometer-three-quarters:before{content:"\f2c8"}.fa-thermometer-2:before,.fa-thermometer-half:before{content:"\f2c9"}.fa-thermometer-1:before,.fa-thermometer-quarter:before{content:"\f2ca"}.fa-thermometer-0:before,.fa-thermometer-empty:before{content:"\f2cb"}.fa-shower:before{content:"\f2cc"}.fa-bathtub:before,.fa-s15:before,.fa-bath:before{content:"\f2cd"}.fa-podcast:before{content:"\f2ce"}.fa-window-maximize:before{content:"\f2d0"}.fa-window-minimize:before{content:"\f2d1"}.fa-window-restore:before{content:"\f2d2"}.fa-times-rectangle:before,.fa-window-close:before{content:"\f2d3"}.fa-times-rectangle-o:before,.fa-window-close-o:before{content:"\f2d4"}.fa-bandcamp:before{content:"\f2d5"}.fa-grav:before{content:"\f2d6"}.fa-etsy:before{content:"\f2d7"}.fa-imdb:before{content:"\f2d8"}.fa-ravelry:before{content:"\f2d9"}.fa-eercast:before{content:"\f2da"}.fa-microchip:before{content:"\f2db"}.fa-snowflake-o:before{content:"\f2dc"}.fa-superpowers:before{content:"\f2dd"}.fa-wpexplorer:before{content:"\f2de"}.fa-meetup:before{content:"\f2e0"}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0, 0, 0, 0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto} diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/fonts/fontawesome-webfont.eot b/netbox/project-static/select2-4.0.12/docs/themes/learn2/fonts/fontawesome-webfont.eot deleted file mode 100644 index e9f60ca95..000000000 Binary files a/netbox/project-static/select2-4.0.12/docs/themes/learn2/fonts/fontawesome-webfont.eot and /dev/null differ diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/fonts/fontawesome-webfont.svg b/netbox/project-static/select2-4.0.12/docs/themes/learn2/fonts/fontawesome-webfont.svg deleted file mode 100644 index 855c845e5..000000000 --- a/netbox/project-static/select2-4.0.12/docs/themes/learn2/fonts/fontawesome-webfont.svg +++ /dev/null @@ -1,2671 +0,0 @@ - - - - -Created by FontForge 20120731 at Mon Oct 24 17:37:40 2016 - By ,,, -Copyright Dave Gandy 2016. All rights reserved. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/fonts/fontawesome-webfont.ttf b/netbox/project-static/select2-4.0.12/docs/themes/learn2/fonts/fontawesome-webfont.ttf deleted file mode 100644 index 35acda2fa..000000000 Binary files a/netbox/project-static/select2-4.0.12/docs/themes/learn2/fonts/fontawesome-webfont.ttf and /dev/null differ diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/fonts/fontawesome-webfont.woff b/netbox/project-static/select2-4.0.12/docs/themes/learn2/fonts/fontawesome-webfont.woff deleted file mode 100644 index 400014a4b..000000000 Binary files a/netbox/project-static/select2-4.0.12/docs/themes/learn2/fonts/fontawesome-webfont.woff and /dev/null differ diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/fonts/fontawesome-webfont.woff2 b/netbox/project-static/select2-4.0.12/docs/themes/learn2/fonts/fontawesome-webfont.woff2 deleted file mode 100644 index 4d13fc604..000000000 Binary files a/netbox/project-static/select2-4.0.12/docs/themes/learn2/fonts/fontawesome-webfont.woff2 and /dev/null differ diff --git a/netbox/project-static/select2-4.0.12/.editorconfig b/netbox/project-static/select2-4.0.13/.editorconfig similarity index 100% rename from netbox/project-static/select2-4.0.12/.editorconfig rename to netbox/project-static/select2-4.0.13/.editorconfig diff --git a/netbox/project-static/select2-4.0.12/.github/CONTRIBUTING.md b/netbox/project-static/select2-4.0.13/.github/CONTRIBUTING.md similarity index 100% rename from netbox/project-static/select2-4.0.12/.github/CONTRIBUTING.md rename to netbox/project-static/select2-4.0.13/.github/CONTRIBUTING.md diff --git a/netbox/project-static/select2-4.0.13/.github/FUNDING.yml b/netbox/project-static/select2-4.0.13/.github/FUNDING.yml new file mode 100644 index 000000000..d8a62dbc7 --- /dev/null +++ b/netbox/project-static/select2-4.0.13/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: kevin-brown +open_collective: select2 diff --git a/netbox/project-static/select2-4.0.12/.github/ISSUE_TEMPLATE.md b/netbox/project-static/select2-4.0.13/.github/ISSUE_TEMPLATE.md similarity index 100% rename from netbox/project-static/select2-4.0.12/.github/ISSUE_TEMPLATE.md rename to netbox/project-static/select2-4.0.13/.github/ISSUE_TEMPLATE.md diff --git a/netbox/project-static/select2-4.0.12/.github/PULL_REQUEST_TEMPLATE.md b/netbox/project-static/select2-4.0.13/.github/PULL_REQUEST_TEMPLATE.md similarity index 100% rename from netbox/project-static/select2-4.0.12/.github/PULL_REQUEST_TEMPLATE.md rename to netbox/project-static/select2-4.0.13/.github/PULL_REQUEST_TEMPLATE.md diff --git a/netbox/project-static/select2-4.0.12/.github/stale.yml b/netbox/project-static/select2-4.0.13/.github/stale.yml similarity index 100% rename from netbox/project-static/select2-4.0.12/.github/stale.yml rename to netbox/project-static/select2-4.0.13/.github/stale.yml diff --git a/netbox/project-static/select2-4.0.12/.github/workflows/docs-deploy.yml b/netbox/project-static/select2-4.0.13/.github/workflows/docs-deploy.yml similarity index 100% rename from netbox/project-static/select2-4.0.12/.github/workflows/docs-deploy.yml rename to netbox/project-static/select2-4.0.13/.github/workflows/docs-deploy.yml diff --git a/netbox/project-static/select2-4.0.12/.github/workflows/main.yml b/netbox/project-static/select2-4.0.13/.github/workflows/main.yml similarity index 100% rename from netbox/project-static/select2-4.0.12/.github/workflows/main.yml rename to netbox/project-static/select2-4.0.13/.github/workflows/main.yml diff --git a/netbox/project-static/select2-4.0.12/.github/workflows/package-deploy.yml b/netbox/project-static/select2-4.0.13/.github/workflows/package-deploy.yml similarity index 100% rename from netbox/project-static/select2-4.0.12/.github/workflows/package-deploy.yml rename to netbox/project-static/select2-4.0.13/.github/workflows/package-deploy.yml diff --git a/netbox/project-static/select2-4.0.12/.gitignore b/netbox/project-static/select2-4.0.13/.gitignore similarity index 100% rename from netbox/project-static/select2-4.0.12/.gitignore rename to netbox/project-static/select2-4.0.13/.gitignore diff --git a/netbox/project-static/select2-4.0.12/.jshintignore b/netbox/project-static/select2-4.0.13/.jshintignore similarity index 100% rename from netbox/project-static/select2-4.0.12/.jshintignore rename to netbox/project-static/select2-4.0.13/.jshintignore diff --git a/netbox/project-static/select2-4.0.12/.jshintrc b/netbox/project-static/select2-4.0.13/.jshintrc similarity index 100% rename from netbox/project-static/select2-4.0.12/.jshintrc rename to netbox/project-static/select2-4.0.13/.jshintrc diff --git a/netbox/project-static/select2-4.0.12/CHANGELOG.md b/netbox/project-static/select2-4.0.13/CHANGELOG.md similarity index 98% rename from netbox/project-static/select2-4.0.12/CHANGELOG.md rename to netbox/project-static/select2-4.0.13/CHANGELOG.md index c620e4d44..d6b2a7587 100644 --- a/netbox/project-static/select2-4.0.12/CHANGELOG.md +++ b/netbox/project-static/select2-4.0.13/CHANGELOG.md @@ -1,5 +1,25 @@ # Change Log +## 4.0.13 + +### New features / improvements + +* Trigger `input` event before `change` events (#4649) +* Feed back the keypress code that was responsible for the 'close' event (#5513) +* Only trigger `selection:update` once on DOM change events (#5734) + +### Bug fixes + +* Prevent opening of disabled elements (#5751) + +### Documentation + +* Fix "edit this page" links in docs (#5689) + +### Miscellaneous + +* Registered Select2 on Open Collective (#5700, #5721, #5741) + ## 4.0.12 ### Bug fixes diff --git a/netbox/project-static/select2-4.0.12/Gruntfile.js b/netbox/project-static/select2-4.0.13/Gruntfile.js similarity index 100% rename from netbox/project-static/select2-4.0.12/Gruntfile.js rename to netbox/project-static/select2-4.0.13/Gruntfile.js diff --git a/netbox/project-static/select2-4.0.12/LICENSE.md b/netbox/project-static/select2-4.0.13/LICENSE.md similarity index 100% rename from netbox/project-static/select2-4.0.12/LICENSE.md rename to netbox/project-static/select2-4.0.13/LICENSE.md diff --git a/netbox/project-static/select2-4.0.12/README.md b/netbox/project-static/select2-4.0.13/README.md similarity index 66% rename from netbox/project-static/select2-4.0.12/README.md rename to netbox/project-static/select2-4.0.13/README.md index bd215280d..a27484fa4 100644 --- a/netbox/project-static/select2-4.0.12/README.md +++ b/netbox/project-static/select2-4.0.13/README.md @@ -1,7 +1,7 @@ Select2 ======= ![Build Status][github-actions-image] -[![cdnjs](https://img.shields.io/cdnjs/v/select2.svg)][cdnjs] +[![Financial Contributors on Open Collective](https://opencollective.com/select2/all/badge.svg?label=financial+contributors)](https://opencollective.com/select2) [![cdnjs](https://img.shields.io/cdnjs/v/select2.svg)][cdnjs] [![jsdelivr](https://data.jsdelivr.com/v1/package/npm/select2/badge)][jsdelivr] Select2 is a jQuery-based replacement for select boxes. It supports searching, @@ -125,3 +125,33 @@ The license is available within the repository in the [LICENSE][license] file. [wicketstuff-select2]: https://github.com/wicketstuff/core/tree/master/select2-parent [yii2]: http://www.yiiframework.com/ [yii2-widget-select2]: https://github.com/kartik-v/yii2-widget-select2 + +## Contributors + +### Code Contributors + +This project exists thanks to all the people who contribute. [[Contribute](.github/CONTRIBUTING.md)]. + + +### Financial Contributors + +Become a financial contributor and help us sustain our community. [[Contribute](https://opencollective.com/select2/contribute)] + +#### Individuals + + + +#### Organizations + +Support this project with your organization. Your logo will show up here with a link to your website. [[Contribute](https://opencollective.com/select2/contribute)] + + + + + + + + + + + diff --git a/netbox/project-static/select2-4.0.12/bower.json b/netbox/project-static/select2-4.0.13/bower.json similarity index 100% rename from netbox/project-static/select2-4.0.12/bower.json rename to netbox/project-static/select2-4.0.13/bower.json diff --git a/netbox/project-static/select2-4.0.12/component.json b/netbox/project-static/select2-4.0.13/component.json similarity index 95% rename from netbox/project-static/select2-4.0.12/component.json rename to netbox/project-static/select2-4.0.13/component.json index 3cfaf0c6e..a8e74c524 100644 --- a/netbox/project-static/select2-4.0.12/component.json +++ b/netbox/project-static/select2-4.0.13/component.json @@ -2,7 +2,7 @@ "name": "select2", "repo": "select/select2", "description": "Select2 is a jQuery based replacement for select boxes. It supports searching, remote data sets, and infinite scrolling of results.", - "version": "4.0.12", + "version": "4.0.13", "demo": "https://select2.org/", "keywords": [ "jquery" diff --git a/netbox/project-static/select2-4.0.12/composer.json b/netbox/project-static/select2-4.0.13/composer.json similarity index 100% rename from netbox/project-static/select2-4.0.12/composer.json rename to netbox/project-static/select2-4.0.13/composer.json diff --git a/netbox/project-static/select2-4.0.12/dist/css/select2.css b/netbox/project-static/select2-4.0.13/dist/css/select2.css similarity index 100% rename from netbox/project-static/select2-4.0.12/dist/css/select2.css rename to netbox/project-static/select2-4.0.13/dist/css/select2.css diff --git a/netbox/project-static/select2-4.0.12/dist/css/select2.min.css b/netbox/project-static/select2-4.0.13/dist/css/select2.min.css similarity index 100% rename from netbox/project-static/select2-4.0.12/dist/css/select2.min.css rename to netbox/project-static/select2-4.0.13/dist/css/select2.min.css diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/af.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/af.js similarity index 92% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/af.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/af.js index 60a2ef329..32e5ac7de 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/af.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/af.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var e=jQuery.fn.select2.amd;e.define("select2/i18n/af",[],function(){return{errorLoading:function(){return"Die resultate kon nie gelaai word nie."},inputTooLong:function(e){var n=e.input.length-e.maximum,r="Verwyders asseblief "+n+" character";return 1!=n&&(r+="s"),r},inputTooShort:function(e){return"Voer asseblief "+(e.minimum-e.input.length)+" of meer karakters"},loadingMore:function(){return"Meer resultate word gelaaiâ€Ļ"},maximumSelected:function(e){var n="Kies asseblief net "+e.maximum+" item";return 1!=e.maximum&&(n+="s"),n},noResults:function(){return"Geen resultate gevind"},searching:function(){return"Besigâ€Ļ"},removeAllItems:function(){return"Verwyder alle items"}}}),e.define,e.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/ar.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/ar.js similarity index 92% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/ar.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/ar.js index 5866da06a..64e1caad3 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/ar.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/ar.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var n=jQuery.fn.select2.amd;n.define("select2/i18n/ar",[],function(){return{errorLoading:function(){return"Ų„Ø§ ŲŠŲ…ŲƒŲ† ØĒØ­Ų…ŲŠŲ„ Ø§Ų„Ų†ØĒاØĻØŦ"},inputTooLong:function(n){return"Ø§Ų„ØąØŦØ§ØĄ Ø­Ø°Ų "+(n.input.length-n.maximum)+" ØšŲ†Ø§ØĩØą"},inputTooShort:function(n){return"Ø§Ų„ØąØŦØ§ØĄ ØĨØļØ§ŲØŠ "+(n.minimum-n.input.length)+" ØšŲ†Ø§ØĩØą"},loadingMore:function(){return"ØŦØ§ØąŲŠ ØĒØ­Ų…ŲŠŲ„ Ų†ØĒاØĻØŦ ØĨØļØ§ŲŲŠØŠ..."},maximumSelected:function(n){return"ØĒØŗØĒØˇŲŠØš ØĨØŽØĒŲŠØ§Øą "+n.maximum+" Ø¨Ų†ŲˆØ¯ ŲŲ‚Øˇ"},noResults:function(){return"Ų„Ų… ؊ØĒŲ… Ø§Ų„ØšØĢŲˆØą ØšŲ„Ų‰ ØŖŲŠ Ų†ØĒاØĻØŦ"},searching:function(){return"ØŦØ§ØąŲŠ Ø§Ų„Ø¨Ø­ØĢâ€Ļ"},removeAllItems:function(){return"Ų‚Ų… بØĨØ˛Ø§Ų„ØŠ ŲƒŲ„ Ø§Ų„ØšŲ†Ø§ØĩØą"}}}),n.define,n.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/az.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/az.js similarity index 91% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/az.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/az.js index f15047a50..1d52c260f 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/az.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/az.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var n=jQuery.fn.select2.amd;n.define("select2/i18n/az",[],function(){return{inputTooLong:function(n){return n.input.length-n.maximum+" simvol silin"},inputTooShort:function(n){return n.minimum-n.input.length+" simvol daxil edin"},loadingMore:function(){return"Daha çox nəticə yÃŧklənirâ€Ļ"},maximumSelected:function(n){return"Sadəcə "+n.maximum+" element seçə bilərsiniz"},noResults:function(){return"Nəticə tapÄąlmadÄą"},searching:function(){return"AxtarÄąlÄąrâ€Ļ"},removeAllItems:function(){return"BÃŧtÃŧn elementləri sil"}}}),n.define,n.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/bg.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/bg.js similarity index 93% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/bg.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/bg.js index ad8915ee4..73b730a70 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/bg.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/bg.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var n=jQuery.fn.select2.amd;n.define("select2/i18n/bg",[],function(){return{inputTooLong:function(n){var e=n.input.length-n.maximum,u="МоĐģŅ Đ˛ŅŠĐ˛ĐĩĐ´ĐĩŅ‚Đĩ ҁ "+e+" ĐŋĐž-ĐŧаĐģĐēĐž ŅĐ¸ĐŧвОĐģ";return e>1&&(u+="a"),u},inputTooShort:function(n){var e=n.minimum-n.input.length,u="МоĐģŅ Đ˛ŅŠĐ˛ĐĩĐ´ĐĩŅ‚Đĩ ĐžŅ‰Đĩ "+e+" ŅĐ¸ĐŧвОĐģ";return e>1&&(u+="a"),u},loadingMore:function(){return"Đ—Đ°Ņ€ĐĩĐļĐ´Đ°Ņ‚ ҁĐĩ ĐžŅ‰Đĩâ€Ļ"},maximumSelected:function(n){var e="МоĐļĐĩŅ‚Đĩ да ĐŊаĐŋŅ€Đ°Đ˛Đ¸Ņ‚Đĩ Đ´Đž "+n.maximum+" ";return n.maximum>1?e+="Đ¸ĐˇĐąĐžŅ€Đ°":e+="Đ¸ĐˇĐąĐžŅ€",e},noResults:function(){return"ĐŅĐŧа ĐŊаĐŧĐĩŅ€ĐĩĐŊи ŅŅŠĐ˛ĐŋадĐĩĐŊĐ¸Ņ"},searching:function(){return"ĐĸŅŠŅ€ŅĐĩĐŊĐĩâ€Ļ"},removeAllItems:function(){return"ĐŸŅ€ĐĩĐŧĐ°Ņ…ĐŊĐĩŅ‚Đĩ Đ˛ŅĐ¸Ņ‡Đēи ĐĩĐģĐĩĐŧĐĩĐŊŅ‚Đ¸"}}}),n.define,n.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/bn.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/bn.js similarity index 95% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/bn.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/bn.js index e2a3926a9..2d17b9d8e 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/bn.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/bn.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var n=jQuery.fn.select2.amd;n.define("select2/i18n/bn",[],function(){return{errorLoading:function(){return"āĻĢāϞāĻžāĻĢāϞāϗ⧁āϞāĻŋ āϞ⧋āĻĄ āĻ•āϰāĻž āϝāĻžāϝāĻŧāύāĻŋāĨ¤"},inputTooLong:function(n){var e=n.input.length-n.maximum,u="āĻ…āύ⧁āĻ—ā§āϰāĻš āĻ•āϰ⧇ "+e+" āϟāĻŋ āĻ…āĻ•ā§āώāϰ āĻŽā§āϛ⧇ āĻĻāĻŋāύāĨ¤";return 1!=e&&(u="āĻ…āύ⧁āĻ—ā§āϰāĻš āĻ•āϰ⧇ "+e+" āϟāĻŋ āĻ…āĻ•ā§āώāϰ āĻŽā§āϛ⧇ āĻĻāĻŋāύāĨ¤"),u},inputTooShort:function(n){return n.minimum-n.input.length+" āϟāĻŋ āĻ…āĻ•ā§āώāϰ āĻ…āĻĨāĻŦāĻž āĻ…āϧāĻŋāĻ• āĻ…āĻ•ā§āώāϰ āϞāĻŋāϖ⧁āύāĨ¤"},loadingMore:function(){return"āφāϰ⧋ āĻĢāϞāĻžāĻĢāϞ āϞ⧋āĻĄ āĻšāĻšā§āϛ⧇ ..."},maximumSelected:function(n){var e=n.maximum+" āϟāĻŋ āφāχāĻŸā§‡āĻŽ āύāĻŋāĻ°ā§āĻŦāĻžāϚāύ āĻ•āϰāϤ⧇ āĻĒāĻžāϰāĻŦ⧇āύāĨ¤";return 1!=n.maximum&&(e=n.maximum+" āϟāĻŋ āφāχāĻŸā§‡āĻŽ āύāĻŋāĻ°ā§āĻŦāĻžāϚāύ āĻ•āϰāϤ⧇ āĻĒāĻžāϰāĻŦ⧇āύāĨ¤"),e},noResults:function(){return"āϕ⧋āύ āĻĢāϞāĻžāĻĢāϞ āĻĒāĻžāĻ“āϝāĻŧāĻž āϝāĻžāϝāĻŧāύāĻŋāĨ¤"},searching:function(){return"āĻ…āύ⧁āϏāĻ¨ā§āϧāĻžāύ āĻ•āϰāĻž āĻšāĻšā§āϛ⧇ ..."}}}),n.define,n.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/bs.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/bs.js similarity index 93% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/bs.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/bs.js index 89a88988f..46b084d75 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/bs.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/bs.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var e=jQuery.fn.select2.amd;e.define("select2/i18n/bs",[],function(){function e(e,n,r,t){return e%10==1&&e%100!=11?n:e%10>=2&&e%10<=4&&(e%100<12||e%100>14)?r:t}return{errorLoading:function(){return"Preuzimanje nije uspijelo."},inputTooLong:function(n){var r=n.input.length-n.maximum,t="ObriÅĄite "+r+" simbol";return t+=e(r,"","a","a")},inputTooShort:function(n){var r=n.minimum-n.input.length,t="Ukucajte bar joÅĄ "+r+" simbol";return t+=e(r,"","a","a")},loadingMore:function(){return"Preuzimanje joÅĄ rezultataâ€Ļ"},maximumSelected:function(n){var r="MoÅžete izabrati samo "+n.maximum+" stavk";return r+=e(n.maximum,"u","e","i")},noResults:function(){return"NiÅĄta nije pronađeno"},searching:function(){return"Pretragaâ€Ļ"},removeAllItems:function(){return"Uklonite sve stavke"}}}),e.define,e.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/ca.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/ca.js similarity index 92% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/ca.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/ca.js index 9e9e5ab97..82dbbb7a2 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/ca.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/ca.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var e=jQuery.fn.select2.amd;e.define("select2/i18n/ca",[],function(){return{errorLoading:function(){return"La càrrega ha fallat"},inputTooLong:function(e){var n=e.input.length-e.maximum,r="Si us plau, elimina "+n+" car";return r+=1==n?"àcter":"àcters"},inputTooShort:function(e){var n=e.minimum-e.input.length,r="Si us plau, introdueix "+n+" car";return r+=1==n?"àcter":"àcters"},loadingMore:function(){return"Carregant mÊs resultatsâ€Ļ"},maximumSelected:function(e){var n="NomÊs es pot seleccionar "+e.maximum+" element";return 1!=e.maximum&&(n+="s"),n},noResults:function(){return"No s'han trobat resultats"},searching:function(){return"Cercantâ€Ļ"},removeAllItems:function(){return"Treu tots els elements"}}}),e.define,e.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/cs.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/cs.js similarity index 95% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/cs.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/cs.js index 3b05cec10..7116d6c1d 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/cs.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/cs.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var e=jQuery.fn.select2.amd;e.define("select2/i18n/cs",[],function(){function e(e,n){switch(e){case 2:return n?"dva":"dvě";case 3:return"tři";case 4:return"čtyři"}return""}return{errorLoading:function(){return"VÃŊsledky nemohly bÃŊt načteny."},inputTooLong:function(n){var t=n.input.length-n.maximum;return 1==t?"Prosím, zadejte o jeden znak mÊně.":t<=4?"Prosím, zadejte o "+e(t,!0)+" znaky mÊně.":"Prosím, zadejte o "+t+" znaků mÊně."},inputTooShort:function(n){var t=n.minimum-n.input.length;return 1==t?"Prosím, zadejte jeÅĄtě jeden znak.":t<=4?"Prosím, zadejte jeÅĄtě dalÅĄÃ­ "+e(t,!0)+" znaky.":"Prosím, zadejte jeÅĄtě dalÅĄÃ­ch "+t+" znaků."},loadingMore:function(){return"Načítají se dalÅĄÃ­ vÃŊsledkyâ€Ļ"},maximumSelected:function(n){var t=n.maximum;return 1==t?"MůŞete zvolit jen jednu poloÅžku.":t<=4?"MůŞete zvolit maximÃĄlně "+e(t,!1)+" poloÅžky.":"MůŞete zvolit maximÃĄlně "+t+" poloÅžek."},noResults:function(){return"Nenalezeny ÅžÃĄdnÊ poloÅžky."},searching:function(){return"VyhledÃĄvÃĄníâ€Ļ"},removeAllItems:function(){return"Odstraňte vÅĄechny poloÅžky"}}}),e.define,e.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/da.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/da.js similarity index 92% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/da.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/da.js index cfa74f485..cda32c34a 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/da.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/da.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var e=jQuery.fn.select2.amd;e.define("select2/i18n/da",[],function(){return{errorLoading:function(){return"Resultaterne kunne ikke indlÃĻses."},inputTooLong:function(e){return"Angiv venligst "+(e.input.length-e.maximum)+" tegn mindre"},inputTooShort:function(e){return"Angiv venligst "+(e.minimum-e.input.length)+" tegn mere"},loadingMore:function(){return"IndlÃĻser flere resultaterâ€Ļ"},maximumSelected:function(e){var n="Du kan kun vÃĻlge "+e.maximum+" emne";return 1!=e.maximum&&(n+="r"),n},noResults:function(){return"Ingen resultater fundet"},searching:function(){return"Søgerâ€Ļ"},removeAllItems:function(){return"Fjern alle elementer"}}}),e.define,e.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/de.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/de.js similarity index 92% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/de.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/de.js index 57e210f81..c2e61e580 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/de.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/de.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var e=jQuery.fn.select2.amd;e.define("select2/i18n/de",[],function(){return{errorLoading:function(){return"Die Ergebnisse konnten nicht geladen werden."},inputTooLong:function(e){return"Bitte "+(e.input.length-e.maximum)+" Zeichen weniger eingeben"},inputTooShort:function(e){return"Bitte "+(e.minimum-e.input.length)+" Zeichen mehr eingeben"},loadingMore:function(){return"Lade mehr Ergebnisseâ€Ļ"},maximumSelected:function(e){var n="Sie kÃļnnen nur "+e.maximum+" Element";return 1!=e.maximum&&(n+="e"),n+=" auswählen"},noResults:function(){return"Keine Übereinstimmungen gefunden"},searching:function(){return"Sucheâ€Ļ"},removeAllItems:function(){return"Entferne alle Elemente"}}}),e.define,e.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/dsb.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/dsb.js similarity index 93% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/dsb.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/dsb.js index 7bee0a002..02f283aba 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/dsb.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/dsb.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var n=jQuery.fn.select2.amd;n.define("select2/i18n/dsb",[],function(){var n=["znamuÅĄko","znamuÅĄce","znamuÅĄka","znamuÅĄkow"],e=["zapisk","zapiska","zapiski","zapiskow"],u=function(n,e){return 1===n?e[0]:2===n?e[1]:n>2&&n<=4?e[2]:n>=5?e[3]:void 0};return{errorLoading:function(){return"Wuslědki njejsu se dali zacytaś."},inputTooLong:function(e){var a=e.input.length-e.maximum;return"PÅĄosym laÅĄuj "+a+" "+u(a,n)},inputTooShort:function(e){var a=e.minimum-e.input.length;return"PÅĄosym zapÃŗdaj nanejmjenjej "+a+" "+u(a,n)},loadingMore:function(){return"DalÅĄne wuslědki se zacytajuâ€Ļ"},maximumSelected:function(n){return"MÃŗÅžoÅĄ jano "+n.maximum+" "+u(n.maximum,e)+"wubraś."},noResults:function(){return"ÅŊedne wuslědki namakane"},searching:function(){return"Pyta seâ€Ļ"},removeAllItems:function(){return"Remove all items"}}}),n.define,n.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/el.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/el.js similarity index 94% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/el.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/el.js index d345ab213..d4922a1df 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/el.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/el.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var n=jQuery.fn.select2.amd;n.define("select2/i18n/el",[],function(){return{errorLoading:function(){return"Τι ÎąĪ€ÎŋĪ„ÎĩÎģÎ­ĪƒÎŧÎąĪ„Îą δÎĩÎŊ ÎŧĪ€ĪŒĪÎĩĪƒÎąÎŊ ÎŊÎą ΆÎŋĪĪ„ĪŽĪƒÎŋĪ…ÎŊ."},inputTooLong:function(n){var e=n.input.length-n.maximum,u="Î ÎąĪÎąÎēÎąÎģĪŽ Î´ÎšÎąÎŗĪÎŦĪˆĪ„Îĩ "+e+" Ī‡ÎąĪÎąÎēĪ„ÎŽĪ";return 1==e&&(u+="Îą"),1!=e&&(u+="ÎĩĪ‚"),u},inputTooShort:function(n){return"Î ÎąĪÎąÎēÎąÎģĪŽ ĪƒĪ…ÎŧĪ€ÎģÎˇĪĪŽĪƒĪ„Îĩ "+(n.minimum-n.input.length)+" ÎŽ Ī€ÎĩĪÎšĪƒĪƒĪŒĪ„Îĩ΁ÎŋĪ…Ī‚ Ī‡ÎąĪÎąÎēĪ„ÎŽĪÎĩĪ‚"},loadingMore:function(){return"ÎĻĪŒĪĪ„Ī‰ĪƒÎˇ Ī€ÎĩĪÎšĪƒĪƒĪŒĪ„Îĩ΁ΉÎŊ ÎąĪ€ÎŋĪ„ÎĩÎģÎĩ΃ÎŧÎŦ΄ΉÎŊâ€Ļ"},maximumSelected:function(n){var e="ÎœĪ€Îŋ΁ÎĩÎ¯Ī„Îĩ ÎŊÎą ÎĩĪ€ÎšÎģέΞÎĩĪ„Îĩ ÎŧΌÎŊÎŋ "+n.maximum+" ÎĩĪ€ÎšÎģÎŋÎŗ";return 1==n.maximum&&(e+="ÎŽ"),1!=n.maximum&&(e+="Î­Ī‚"),e},noResults:function(){return"ΔÎĩÎŊ Î˛ĪÎ­Î¸ÎˇÎēÎąÎŊ ÎąĪ€ÎŋĪ„ÎĩÎģÎ­ĪƒÎŧÎąĪ„Îą"},searching:function(){return"ΑÎŊÎąÎļÎŽĪ„ÎˇĪƒÎˇâ€Ļ"},removeAllItems:function(){return"ÎšÎąĪ„ÎąĪÎŗÎŽĪƒĪ„Îĩ ΌÎģÎą Ī„Îą ĪƒĪ„ÎŋÎšĪ‡ÎĩÎ¯Îą"}}}),n.define,n.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/en.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/en.js similarity index 92% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/en.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/en.js index 5b99c9668..3b1928573 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/en.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/en.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var e=jQuery.fn.select2.amd;e.define("select2/i18n/en",[],function(){return{errorLoading:function(){return"The results could not be loaded."},inputTooLong:function(e){var n=e.input.length-e.maximum,r="Please delete "+n+" character";return 1!=n&&(r+="s"),r},inputTooShort:function(e){return"Please enter "+(e.minimum-e.input.length)+" or more characters"},loadingMore:function(){return"Loading more resultsâ€Ļ"},maximumSelected:function(e){var n="You can only select "+e.maximum+" item";return 1!=e.maximum&&(n+="s"),n},noResults:function(){return"No results found"},searching:function(){return"Searchingâ€Ļ"},removeAllItems:function(){return"Remove all items"}}}),e.define,e.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/es.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/es.js similarity index 93% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/es.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/es.js index 2354519b8..68afd6d25 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/es.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/es.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var e=jQuery.fn.select2.amd;e.define("select2/i18n/es",[],function(){return{errorLoading:function(){return"No se pudieron cargar los resultados"},inputTooLong:function(e){var n=e.input.length-e.maximum,r="Por favor, elimine "+n+" car";return r+=1==n?"ÃĄcter":"acteres"},inputTooShort:function(e){var n=e.minimum-e.input.length,r="Por favor, introduzca "+n+" car";return r+=1==n?"ÃĄcter":"acteres"},loadingMore:function(){return"Cargando mÃĄs resultadosâ€Ļ"},maximumSelected:function(e){var n="SÃŗlo puede seleccionar "+e.maximum+" elemento";return 1!=e.maximum&&(n+="s"),n},noResults:function(){return"No se encontraron resultados"},searching:function(){return"Buscandoâ€Ļ"},removeAllItems:function(){return"Eliminar todos los elementos"}}}),e.define,e.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/et.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/et.js similarity index 92% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/et.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/et.js index af186735b..070b61a26 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/et.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/et.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var e=jQuery.fn.select2.amd;e.define("select2/i18n/et",[],function(){return{inputTooLong:function(e){var n=e.input.length-e.maximum,t="Sisesta "+n+" täht";return 1!=n&&(t+="e"),t+=" vähem"},inputTooShort:function(e){var n=e.minimum-e.input.length,t="Sisesta "+n+" täht";return 1!=n&&(t+="e"),t+=" rohkem"},loadingMore:function(){return"Laen tulemusiâ€Ļ"},maximumSelected:function(e){var n="Saad vaid "+e.maximum+" tulemus";return 1==e.maximum?n+="e":n+="t",n+=" valida"},noResults:function(){return"Tulemused puuduvad"},searching:function(){return"Otsinâ€Ļ"},removeAllItems:function(){return"Eemalda kÃĩik esemed"}}}),e.define,e.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/eu.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/eu.js similarity index 92% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/eu.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/eu.js index 96751ad4b..90d5e73f8 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/eu.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/eu.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var e=jQuery.fn.select2.amd;e.define("select2/i18n/eu",[],function(){return{inputTooLong:function(e){var t=e.input.length-e.maximum,n="Idatzi ";return n+=1==t?"karaktere bat":t+" karaktere",n+=" gutxiago"},inputTooShort:function(e){var t=e.minimum-e.input.length,n="Idatzi ";return n+=1==t?"karaktere bat":t+" karaktere",n+=" gehiago"},loadingMore:function(){return"Emaitza gehiago kargatzenâ€Ļ"},maximumSelected:function(e){return 1===e.maximum?"Elementu bakarra hauta dezakezu":e.maximum+" elementu hauta ditzakezu soilik"},noResults:function(){return"Ez da bat datorrenik aurkitu"},searching:function(){return"Bilatzenâ€Ļ"},removeAllItems:function(){return"Kendu elementu guztiak"}}}),e.define,e.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/fa.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/fa.js similarity index 93% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/fa.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/fa.js index 0180ad1b7..e1ffdbed0 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/fa.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/fa.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var n=jQuery.fn.select2.amd;n.define("select2/i18n/fa",[],function(){return{errorLoading:function(){return"Ø§Ų…ÚŠØ§Ų† Ø¨Ø§ØąÚ¯Ø°Ø§ØąÛŒ Ų†ØĒایØŦ ؈ØŦŲˆØ¯ Ų†Ø¯Ø§ØąØ¯."},inputTooLong:function(n){return"Ų„ØˇŲØ§Ų‹ "+(n.input.length-n.maximum)+" ÚŠØ§ØąØ§ÚŠØĒØą ØąØ§ Ø­Ø°Ų Ų†Ų…Ø§ÛŒÛŒØ¯"},inputTooShort:function(n){return"Ų„ØˇŲØ§Ų‹ ØĒؚداد "+(n.minimum-n.input.length)+" ÚŠØ§ØąØ§ÚŠØĒØą یا بیشØĒØą ŲˆØ§ØąØ¯ Ų†Ų…Ø§ÛŒÛŒØ¯"},loadingMore:function(){return"Ø¯Øą Ø­Ø§Ų„ Ø¨Ø§ØąÚ¯Ø°Ø§ØąÛŒ Ų†ØĒایØŦ بیشØĒØą..."},maximumSelected:function(n){return"Ø´Ų…Ø§ ØĒŲ†Ų‡Ø§ Ų…ÛŒâ€ŒØĒŲˆØ§Ų†ÛŒØ¯ "+n.maximum+" ØĸیØĒŲ… ØąØ§ Ø§Ų†ØĒ؎اب Ų†Ų…Ø§ÛŒÛŒØ¯"},noResults:function(){return"Ų‡ÛŒÚ† Ų†ØĒیØŦŲ‡â€ŒØ§ÛŒ ÛŒØ§ŲØĒ Ų†Ø´Ø¯"},searching:function(){return"Ø¯Øą Ø­Ø§Ų„ ØŦØŗØĒØŦ؈..."},removeAllItems:function(){return"Ų‡Ų…Ų‡ Ų…ŲˆØ§ØąØ¯ ØąØ§ Ø­Ø°Ų ÚŠŲ†ÛŒØ¯"}}}),n.define,n.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/fi.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/fi.js similarity index 92% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/fi.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/fi.js index 630144e10..ffed1247d 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/fi.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/fi.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var n=jQuery.fn.select2.amd;n.define("select2/i18n/fi",[],function(){return{errorLoading:function(){return"Tuloksia ei saatu ladattua."},inputTooLong:function(n){return"Ole hyvä ja anna "+(n.input.length-n.maximum)+" merkkiä vähemmän"},inputTooShort:function(n){return"Ole hyvä ja anna "+(n.minimum-n.input.length)+" merkkiä lisää"},loadingMore:function(){return"Ladataan lisää tuloksiaâ€Ļ"},maximumSelected:function(n){return"Voit valita ainoastaan "+n.maximum+" kpl"},noResults:function(){return"Ei tuloksia"},searching:function(){return"Haetaanâ€Ļ"},removeAllItems:function(){return"Poista kaikki kohteet"}}}),n.define,n.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/fr.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/fr.js similarity index 93% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/fr.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/fr.js index 5c7c285f9..dd02f973f 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/fr.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/fr.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var e=jQuery.fn.select2.amd;e.define("select2/i18n/fr",[],function(){return{errorLoading:function(){return"Les rÊsultats ne peuvent pas ÃĒtre chargÊs."},inputTooLong:function(e){var n=e.input.length-e.maximum;return"Supprimez "+n+" caractère"+(n>1?"s":"")},inputTooShort:function(e){var n=e.minimum-e.input.length;return"Saisissez au moins "+n+" caractère"+(n>1?"s":"")},loadingMore:function(){return"Chargement de rÊsultats supplÊmentairesâ€Ļ"},maximumSelected:function(e){return"Vous pouvez seulement sÊlectionner "+e.maximum+" ÊlÊment"+(e.maximum>1?"s":"")},noResults:function(){return"Aucun rÊsultat trouvÊ"},searching:function(){return"Recherche en coursâ€Ļ"},removeAllItems:function(){return"Supprimer tous les ÊlÊments"}}}),e.define,e.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/gl.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/gl.js similarity index 93% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/gl.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/gl.js index 6a78d84c9..208a00570 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/gl.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/gl.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var e=jQuery.fn.select2.amd;e.define("select2/i18n/gl",[],function(){return{errorLoading:function(){return"Non foi posíbel cargar os resultados."},inputTooLong:function(e){var n=e.input.length-e.maximum;return 1===n?"Elimine un carÃĄcter":"Elimine "+n+" caracteres"},inputTooShort:function(e){var n=e.minimum-e.input.length;return 1===n?"Engada un carÃĄcter":"Engada "+n+" caracteres"},loadingMore:function(){return"Cargando mÃĄis resultadosâ€Ļ"},maximumSelected:function(e){return 1===e.maximum?"SÃŗ pode seleccionar un elemento":"SÃŗ pode seleccionar "+e.maximum+" elementos"},noResults:function(){return"Non se atoparon resultados"},searching:function(){return"Buscandoâ€Ļ"},removeAllItems:function(){return"Elimina todos os elementos"}}}),e.define,e.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/he.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/he.js similarity index 93% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/he.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/he.js index 2904dd221..25a8805aa 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/he.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/he.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var n=jQuery.fn.select2.amd;n.define("select2/i18n/he",[],function(){return{errorLoading:function(){return"שגיאה בט×ĸינ×Ē ×”×Ēו×Ļאו×Ē"},inputTooLong:function(n){var e=n.input.length-n.maximum,r="נא למחוק ";return r+=1===e?"×Ēו אחד":e+" ×Ēווים"},inputTooShort:function(n){var e=n.minimum-n.input.length,r="נא להכניס ";return r+=1===e?"×Ēו אחד":e+" ×Ēווים",r+=" או יו×Ēר"},loadingMore:function(){return"טו×ĸן ×Ēו×Ļאו×Ē × ×•×Ą×¤×•×Ēâ€Ļ"},maximumSelected:function(n){var e="באפשרו×Ēך לבחור ×ĸד ";return 1===n.maximum?e+="פריט אחד":e+=n.maximum+" פריטים",e},noResults:function(){return"לא נמ×Ļאו ×Ēו×Ļאו×Ē"},searching:function(){return"מחפשâ€Ļ"},removeAllItems:function(){return"הסר א×Ē ×›×œ הפריטים"}}}),n.define,n.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/hi.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/hi.js similarity index 94% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/hi.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/hi.js index 9428c29cf..f3ed79843 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/hi.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/hi.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var n=jQuery.fn.select2.amd;n.define("select2/i18n/hi",[],function(){return{errorLoading:function(){return"ā¤Ē⤰ā¤ŋā¤Ŗā¤žā¤ŽāĨ‹ā¤‚ ⤕āĨ‹ ⤞āĨ‹ā¤Ą ā¤¨ā¤šāĨ€ā¤‚ ⤕ā¤ŋā¤¯ā¤ž ā¤œā¤ž ā¤¸ā¤•ā¤žāĨ¤"},inputTooLong:function(n){var e=n.input.length-n.maximum,r=e+" ⤅⤕āĨā¤ˇā¤° ⤕āĨ‹ ā¤šā¤Ÿā¤ž ā¤ĻāĨ‡ā¤‚";return e>1&&(r=e+" ⤅⤕āĨā¤ˇā¤°āĨ‹ā¤‚ ⤕āĨ‹ ā¤šā¤Ÿā¤ž ā¤ĻāĨ‡ā¤‚ "),r},inputTooShort:function(n){return"⤕āĨƒā¤Ēā¤¯ā¤ž "+(n.minimum-n.input.length)+" ā¤¯ā¤ž ⤅⤧ā¤ŋ⤕ ⤅⤕āĨā¤ˇā¤° ā¤Ļ⤰āĨā¤œ ⤕⤰āĨ‡ā¤‚"},loadingMore:function(){return"⤅⤧ā¤ŋ⤕ ā¤Ē⤰ā¤ŋā¤Ŗā¤žā¤Ž ⤞āĨ‹ā¤Ą ā¤šāĨ‹ ā¤°ā¤šāĨ‡ ā¤šāĨˆ..."},maximumSelected:function(n){return"⤆ā¤Ē ⤕āĨ‡ā¤ĩ⤞ "+n.maximum+" ā¤†ā¤‡ā¤Ÿā¤Ž ā¤•ā¤ž ⤚⤝⤍ ⤕⤰ ⤏⤕⤤āĨ‡ ā¤šāĨˆā¤‚"},noResults:function(){return"⤕āĨ‹ā¤ˆ ā¤Ē⤰ā¤ŋā¤Ŗā¤žā¤Ž ā¤¨ā¤šāĨ€ā¤‚ ā¤Žā¤ŋā¤˛ā¤ž"},searching:function(){return"⤖āĨ‹ā¤œ ā¤°ā¤šā¤ž ā¤šāĨˆ..."},removeAllItems:function(){return"⤏⤭āĨ€ ā¤ĩ⤏āĨā¤¤āĨā¤“⤂ ⤕āĨ‹ ā¤šā¤Ÿā¤ž ā¤ĻāĨ‡ā¤‚"}}}),n.define,n.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/hr.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/hr.js similarity index 92% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/hr.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/hr.js index 3a5ede48f..cb3268db1 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/hr.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/hr.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var n=jQuery.fn.select2.amd;n.define("select2/i18n/hr",[],function(){function n(n){var e=" "+n+" znak";return n%10<5&&n%10>0&&(n%100<5||n%100>19)?n%10>1&&(e+="a"):e+="ova",e}return{errorLoading:function(){return"Preuzimanje nije uspjelo."},inputTooLong:function(e){return"Unesite "+n(e.input.length-e.maximum)},inputTooShort:function(e){return"Unesite joÅĄ "+n(e.minimum-e.input.length)},loadingMore:function(){return"Učitavanje rezultataâ€Ļ"},maximumSelected:function(n){return"Maksimalan broj odabranih stavki je "+n.maximum},noResults:function(){return"Nema rezultata"},searching:function(){return"Pretragaâ€Ļ"},removeAllItems:function(){return"Ukloni sve stavke"}}}),n.define,n.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/hsb.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/hsb.js similarity index 93% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/hsb.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/hsb.js index 160318e8e..3d5bf09db 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/hsb.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/hsb.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var n=jQuery.fn.select2.amd;n.define("select2/i18n/hsb",[],function(){var n=["znamjeÅĄko","znamjeÅĄce","znamjeÅĄka","znamjeÅĄkow"],e=["zapisk","zapiskaj","zapiski","zapiskow"],u=function(n,e){return 1===n?e[0]:2===n?e[1]:n>2&&n<=4?e[2]:n>=5?e[3]:void 0};return{errorLoading:function(){return"Wuslědki njedachu so začitać."},inputTooLong:function(e){var a=e.input.length-e.maximum;return"ProÅĄu zhaÅĄej "+a+" "+u(a,n)},inputTooShort:function(e){var a=e.minimum-e.input.length;return"ProÅĄu zapodaj znajmjeÅ„ÅĄa "+a+" "+u(a,n)},loadingMore:function(){return"DalÅĄe wuslědki so začitajaâ€Ļ"},maximumSelected:function(n){return"MÃŗÅžeÅĄ jenoÅž "+n.maximum+" "+u(n.maximum,e)+"wubrać"},noResults:function(){return"ÅŊane wuslědki namakane"},searching:function(){return"Pyta soâ€Ļ"},removeAllItems:function(){return"Remove all items"}}}),n.define,n.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/hu.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/hu.js similarity index 92% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/hu.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/hu.js index 73debe098..4893aa2f7 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/hu.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/hu.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var e=jQuery.fn.select2.amd;e.define("select2/i18n/hu",[],function(){return{errorLoading:function(){return"Az eredmÊnyek betÃļltÊse nem sikerÃŧlt."},inputTooLong:function(e){return"TÃēl hosszÃē. "+(e.input.length-e.maximum)+" karakterrel tÃļbb, mint kellene."},inputTooShort:function(e){return"TÃēl rÃļvid. MÊg "+(e.minimum-e.input.length)+" karakter hiÃĄnyzik."},loadingMore:function(){return"TÃļltÊsâ€Ļ"},maximumSelected:function(e){return"Csak "+e.maximum+" elemet lehet kivÃĄlasztani."},noResults:function(){return"Nincs talÃĄlat."},searching:function(){return"KeresÊsâ€Ļ"},removeAllItems:function(){return"TÃĄvolítson el minden elemet"}}}),e.define,e.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/hy.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/hy.js similarity index 93% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/hy.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/hy.js index d7f11dcd0..823000714 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/hy.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/hy.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var n=jQuery.fn.select2.amd;n.define("select2/i18n/hy",[],function(){return{errorLoading:function(){return"ÔąÖ€Õ¤ÕĩուÕļքÕļÕĨրը Õ°ÕļÕĄÖ€ÕĄÕžÕ¸Ö€ ÕšÕ§ ÕĸÕĨÕŧÕļÕĨÕŦ։"},inputTooLong:function(n){return"ÔŊÕļդրում ÕĨÕļք Õ°ÕĨÕŧÕĄÖÕļÕĨÕŦ "+(n.input.length-n.maximum)+" ÕļÕˇÕĄÕļ"},inputTooShort:function(n){return"ÔŊÕļդրում ÕĨÕļք մուÕŋÖ„ÕĄÕŖÖ€ÕĨÕŦ "+(n.minimum-n.input.length)+" Õ¯ÕĄÕ´ ÕĄÕžÕĨÕŦ ÕļÕˇÕĄÕļÕļÕĨր"},loadingMore:function(){return"Ô˛ÕĨÕŧÕļÕžÕ¸Ö‚Õ´ ÕĨÕļ Õļոր ÕĄÖ€Õ¤ÕĩուÕļքÕļÕĨր․․․"},maximumSelected:function(n){return"Դուք Õ¯ÕĄÖ€Õ¸Õ˛ ÕĨք Õ¨ÕļÕŋրÕĨÕŦ ÕĄÕŧÕĄÕžÕĨÕŦÕĄÕŖÕ¸Ö‚ÕĩÕļÕ¨ "+n.maximum+" Õ¯ÕĨÕŋ"},noResults:function(){return"ÔąÖ€Õ¤ÕĩուÕļքÕļÕĨր ÕšÕĨÕļ ÕŖÕŋÕļÕžÕĨÕŦ"},searching:function(){return"ՈրոÕļում․․․"},removeAllItems:function(){return"ՀÕĨÕŧÕĄÖÕļÕĨÕŦ ÕĸÕ¸ÕŦոր ÕŋÕĄÖ€Ö€ÕĨրը"}}}),n.define,n.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/id.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/id.js similarity index 91% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/id.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/id.js index f9479b602..4a0b3bf00 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/id.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/id.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var n=jQuery.fn.select2.amd;n.define("select2/i18n/id",[],function(){return{errorLoading:function(){return"Data tidak boleh diambil."},inputTooLong:function(n){return"Hapuskan "+(n.input.length-n.maximum)+" huruf"},inputTooShort:function(n){return"Masukkan "+(n.minimum-n.input.length)+" huruf lagi"},loadingMore:function(){return"Mengambil dataâ€Ļ"},maximumSelected:function(n){return"Anda hanya dapat memilih "+n.maximum+" pilihan"},noResults:function(){return"Tidak ada data yang sesuai"},searching:function(){return"Mencariâ€Ļ"},removeAllItems:function(){return"Hapus semua item"}}}),n.define,n.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/is.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/is.js similarity index 92% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/is.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/is.js index b6770703e..cca5bbecf 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/is.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/is.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var n=jQuery.fn.select2.amd;n.define("select2/i18n/is",[],function(){return{inputTooLong:function(n){var t=n.input.length-n.maximum,e="Vinsamlegast styttið texta um "+t+" staf";return t<=1?e:e+"i"},inputTooShort:function(n){var t=n.minimum-n.input.length,e="Vinsamlegast skrifið "+t+" staf";return t>1&&(e+="i"),e+=" í viðbÃŗt"},loadingMore:function(){return"SÃĻki fleiri niðurstÃļðurâ€Ļ"},maximumSelected:function(n){return"ÞÃē getur aðeins valið "+n.maximum+" atriði"},noResults:function(){return"Ekkert fannst"},searching:function(){return"Leitaâ€Ļ"},removeAllItems:function(){return"FjarlÃĻgðu Ãļll atriði"}}}),n.define,n.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/it.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/it.js similarity index 92% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/it.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/it.js index 05f87cf04..507c7d9f2 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/it.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/it.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var e=jQuery.fn.select2.amd;e.define("select2/i18n/it",[],function(){return{errorLoading:function(){return"I risultati non possono essere caricati."},inputTooLong:function(e){var n=e.input.length-e.maximum,t="Per favore cancella "+n+" caratter";return t+=1!==n?"i":"e"},inputTooShort:function(e){return"Per favore inserisci "+(e.minimum-e.input.length)+" o piÚ caratteri"},loadingMore:function(){return"Caricando piÚ risultatiâ€Ļ"},maximumSelected:function(e){var n="Puoi selezionare solo "+e.maximum+" element";return 1!==e.maximum?n+="i":n+="o",n},noResults:function(){return"Nessun risultato trovato"},searching:function(){return"Sto cercandoâ€Ļ"},removeAllItems:function(){return"Rimuovi tutti gli oggetti"}}}),e.define,e.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/ja.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/ja.js similarity index 92% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/ja.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/ja.js index 3f546061e..451025e2c 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/ja.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/ja.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var n=jQuery.fn.select2.amd;n.define("select2/i18n/ja",[],function(){return{errorLoading:function(){return"įĩæžœãŒčĒ­ãŋčžŧぞれぞせんでした"},inputTooLong:function(n){return n.input.length-n.maximum+" 文字を削除しãĻください"},inputTooShort:function(n){return"少ãĒくとも "+(n.minimum-n.input.length)+" 文字をå…Ĩ力しãĻください"},loadingMore:function(){return"čĒ­ãŋčžŧãŋ中â€Ļ"},maximumSelected:function(n){return n.maximum+" äģļしか選択できぞせん"},noResults:function(){return"å¯žčąĄãŒčĻ‹ã¤ã‹ã‚Šãžã›ã‚“"},searching:function(){return"検į´ĸしãĻいぞすâ€Ļ"},removeAllItems:function(){return"すずãĻぎã‚ĸイテムを削除"}}}),n.define,n.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/ka.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/ka.js similarity index 94% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/ka.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/ka.js index 5d3ca4acc..60c593b70 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/ka.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/ka.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var n=jQuery.fn.select2.amd;n.define("select2/i18n/ka",[],function(){return{errorLoading:function(){return"მონაáƒĒემების ჊აáƒĸვირთვა áƒ¨áƒ”áƒŖáƒĢლებელია."},inputTooLong:function(n){return"გთხოვთ აკრიფეთ "+(n.input.length-n.maximum)+" სიმბოლოთი ნაკლები"},inputTooShort:function(n){return"გთხოვთ აკრიფეთ "+(n.minimum-n.input.length)+" სიმბოლო ან მეáƒĸი"},loadingMore:function(){return"მონაáƒĒემების ჊აáƒĸვირთვაâ€Ļ"},maximumSelected:function(n){return"თáƒĨვენ შეგიáƒĢლიათ აირჩიოთ áƒáƒ áƒáƒŖáƒ›áƒ”áƒĸეს "+n.maximum+" ელემენáƒĸი"},noResults:function(){return"áƒ áƒ”áƒ–áƒŖáƒšáƒĸაáƒĸი არ მოიáƒĢებნა"},searching:function(){return"áƒĢიებაâ€Ļ"},removeAllItems:function(){return"ამოიáƒĻე ყველა ელემენáƒĸი"}}}),n.define,n.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/km.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/km.js similarity index 94% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/km.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/km.js index bd78a0d3e..4dca94f41 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/km.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/km.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var n=jQuery.fn.select2.amd;n.define("select2/i18n/km",[],function(){return{errorLoading:function(){return"មិនážĸážļចទážļញយកទិន្នន័យ"},inputTooLong:function(n){return"សážŧមលážģបចេញ "+(n.input.length-n.maximum)+" ážĸក្សរ"},inputTooShort:function(n){return"សážŧមបញ្ចážŧល"+(n.minimum-n.input.length)+" ážĸក្សរ រážē ច្រើនជážļងនេះ"},loadingMore:function(){return"កំពážģងទážļញយកទិន្នន័យបន្ថែម..."},maximumSelected:function(n){return"ážĸ្នកážĸážļចជ្រើសរើសបážļនតែ "+n.maximum+" ជម្រើសប៉ážģណ្ណោះ"},noResults:function(){return"មិនមážļនលទ្ធផល"},searching:function(){return"កំពážģងស្វែងរក..."},removeAllItems:function(){return"លážģបធážļតážģទážļំងážĸស់"}}}),n.define,n.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/ko.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/ko.js similarity index 92% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/ko.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/ko.js index 91a470aa6..f2880fb00 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/ko.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/ko.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var n=jQuery.fn.select2.amd;n.define("select2/i18n/ko",[],function(){return{errorLoading:function(){return"결ęŗŧëĨŧ ëļˆëŸŦė˜Ŧ 눘 ė—†ėŠĩ니다."},inputTooLong:function(n){return"너ëŦ´ 깁니다. "+(n.input.length-n.maximum)+" ę¸€ėž ė§€ė›ŒėŖŧė„¸ėš”."},inputTooShort:function(n){return"너ëŦ´ ė§§ėŠĩ니다. "+(n.minimum-n.input.length)+" ę¸€ėž 더 ėž…ë Ĩ해ėŖŧė„¸ėš”."},loadingMore:function(){return"ëļˆëŸŦė˜¤ëŠ” 뤑â€Ļ"},maximumSelected:function(n){return"ėĩœëŒ€ "+n.maximum+"ę°œęšŒė§€ë§Œ ė„ íƒ 가ëŠĨ합니다."},noResults:function(){return"결ęŗŧ가 ė—†ėŠĩ니다."},searching:function(){return"ę˛€ėƒ‰ 뤑â€Ļ"},removeAllItems:function(){return"ëĒ¨ë“  항ëĒŠ ė‚­ė œ"}}}),n.define,n.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/lt.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/lt.js similarity index 93% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/lt.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/lt.js index ece6f6927..f6a42155a 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/lt.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/lt.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var n=jQuery.fn.select2.amd;n.define("select2/i18n/lt",[],function(){function n(n,e,i,t){return n%10==1&&(n%100<11||n%100>19)?e:n%10>=2&&n%10<=9&&(n%100<11||n%100>19)?i:t}return{inputTooLong:function(e){var i=e.input.length-e.maximum,t="PaÅĄalinkite "+i+" simbol";return t+=n(i,"į","ius","iÅŗ")},inputTooShort:function(e){var i=e.minimum-e.input.length,t="ÄŽraÅĄykite dar "+i+" simbol";return t+=n(i,"į","ius","iÅŗ")},loadingMore:function(){return"Kraunama daugiau rezultatÅŗâ€Ļ"},maximumSelected:function(e){var i="JÅĢs galite pasirinkti tik "+e.maximum+" element";return i+=n(e.maximum,"ą","us","Åŗ")},noResults:function(){return"AtitikmenÅŗ nerasta"},searching:function(){return"IeÅĄkomaâ€Ļ"},removeAllItems:function(){return"PaÅĄalinti visus elementus"}}}),n.define,n.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/lv.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/lv.js similarity index 92% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/lv.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/lv.js index 815d799b0..806dc5c43 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/lv.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/lv.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var e=jQuery.fn.select2.amd;e.define("select2/i18n/lv",[],function(){function e(e,n,u,i){return 11===e?n:e%10==1?u:i}return{inputTooLong:function(n){var u=n.input.length-n.maximum,i="LÅĢdzu ievadiet par "+u;return(i+=" simbol"+e(u,"iem","u","iem"))+" mazāk"},inputTooShort:function(n){var u=n.minimum-n.input.length,i="LÅĢdzu ievadiet vēl "+u;return i+=" simbol"+e(u,"us","u","us")},loadingMore:function(){return"Datu ielādeâ€Ļ"},maximumSelected:function(n){var u="JÅĢs varat izvēlēties ne vairāk kā "+n.maximum;return u+=" element"+e(n.maximum,"us","u","us")},noResults:function(){return"SakritÄĢbu nav"},searching:function(){return"MeklÄ“ÅĄanaâ€Ļ"},removeAllItems:function(){return"Noņemt visus vienumus"}}}),e.define,e.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/mk.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/mk.js similarity index 93% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/mk.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/mk.js index 79e870e4f..cb7b84a26 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/mk.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/mk.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var n=jQuery.fn.select2.amd;n.define("select2/i18n/mk",[],function(){return{inputTooLong:function(n){var e=(n.input.length,n.maximum,"ВĐĩ ĐŧĐžĐģиĐŧĐĩ вĐŊĐĩҁĐĩŅ‚Đĩ "+n.maximum+" ĐŋĐžĐŧаĐģĐē҃ ĐēĐ°Ņ€Đ°ĐēŅ‚ĐĩŅ€");return 1!==n.maximum&&(e+="и"),e},inputTooShort:function(n){var e=(n.minimum,n.input.length,"ВĐĩ ĐŧĐžĐģиĐŧĐĩ вĐŊĐĩҁĐĩŅ‚Đĩ ŅƒŅˆŅ‚Đĩ "+n.maximum+" ĐēĐ°Ņ€Đ°ĐēŅ‚ĐĩŅ€");return 1!==n.maximum&&(e+="и"),e},loadingMore:function(){return"Đ’Ņ‡Đ¸Ņ‚ŅƒĐ˛Đ°ŅšĐĩ Ņ€ĐĩĐˇŅƒĐģŅ‚Đ°Ņ‚Đ¸â€Ļ"},maximumSelected:function(n){var e="МоĐļĐĩŅ‚Đĩ да иСйĐĩŅ€ĐĩŅ‚Đĩ ŅĐ°ĐŧĐž "+n.maximum+" ŅŅ‚Đ°Đ˛Đē";return 1===n.maximum?e+="а":e+="и",e},noResults:function(){return"НĐĩĐŧа ĐŋŅ€ĐžĐŊĐ°Ņ˜Đ´ĐĩĐŊĐž ŅĐžĐ˛ĐŋĐ°Ņ“Đ°ŅšĐ°"},searching:function(){return"ĐŸŅ€ĐĩĐąĐ°Ņ€ŅƒĐ˛Đ°ŅšĐĩâ€Ļ"},removeAllItems:function(){return"ĐžŅ‚ŅŅ‚Ņ€Đ°ĐŊи ĐŗĐ¸ ŅĐ¸Ņ‚Đĩ ĐŋŅ€ĐĩĐ´ĐŧĐĩŅ‚Đ¸"}}}),n.define,n.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/ms.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/ms.js similarity index 92% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/ms.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/ms.js index d3feef2b5..6bd7eaa3e 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/ms.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/ms.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var n=jQuery.fn.select2.amd;n.define("select2/i18n/ms",[],function(){return{errorLoading:function(){return"Keputusan tidak berjaya dimuatkan."},inputTooLong:function(n){return"Sila hapuskan "+(n.input.length-n.maximum)+" aksara"},inputTooShort:function(n){return"Sila masukkan "+(n.minimum-n.input.length)+" atau lebih aksara"},loadingMore:function(){return"Sedang memuatkan keputusanâ€Ļ"},maximumSelected:function(n){return"Anda hanya boleh memilih "+n.maximum+" pilihan"},noResults:function(){return"Tiada padanan yang ditemui"},searching:function(){return"Mencariâ€Ļ"},removeAllItems:function(){return"Keluarkan semua item"}}}),n.define,n.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/nb.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/nb.js similarity index 91% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/nb.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/nb.js index 953ff21e4..25d89c687 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/nb.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/nb.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var e=jQuery.fn.select2.amd;e.define("select2/i18n/nb",[],function(){return{errorLoading:function(){return"Kunne ikke hente resultater."},inputTooLong:function(e){return"Vennligst fjern "+(e.input.length-e.maximum)+" tegn"},inputTooShort:function(e){return"Vennligst skriv inn "+(e.minimum-e.input.length)+" tegn til"},loadingMore:function(){return"Laster flere resultaterâ€Ļ"},maximumSelected:function(e){return"Du kan velge maks "+e.maximum+" elementer"},noResults:function(){return"Ingen treff"},searching:function(){return"Søkerâ€Ļ"},removeAllItems:function(){return"Fjern alle elementer"}}}),e.define,e.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/ne.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/ne.js similarity index 95% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/ne.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/ne.js index 536fbab84..1c39f6721 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/ne.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/ne.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var n=jQuery.fn.select2.amd;n.define("select2/i18n/ne",[],function(){return{errorLoading:function(){return"⤍⤤ā¤ŋā¤œā¤žā¤šā¤°āĨ ā¤ĻāĨ‡ā¤–ā¤žā¤‰ā¤¨ ⤏⤕ā¤ŋā¤ā¤¨āĨ¤"},inputTooLong:function(n){var e=n.input.length-n.maximum,u="⤕āĨƒā¤Ēā¤¯ā¤ž "+e+" ⤅⤕āĨā¤ˇā¤° ā¤ŽāĨ‡ā¤Ÿā¤žā¤‰ā¤¨āĨā¤šāĨ‹ā¤¸āĨāĨ¤";return 1!=e&&(u+="⤕āĨƒā¤Ēā¤¯ā¤ž "+e+" ⤅⤕āĨā¤ˇā¤°ā¤šā¤°āĨ ā¤ŽāĨ‡ā¤Ÿā¤žā¤‰ā¤¨āĨā¤šāĨ‹ā¤¸āĨāĨ¤"),u},inputTooShort:function(n){return"⤕āĨƒā¤Ēā¤¯ā¤ž ā¤Ŧā¤žā¤ā¤•āĨ€ ā¤°ā¤šāĨ‡ā¤•ā¤ž "+(n.minimum-n.input.length)+" ā¤ĩā¤ž ⤅⤰āĨ ⤧āĨ‡ā¤°āĨˆ ⤅⤕āĨā¤ˇā¤°ā¤šā¤°āĨ ⤭⤰āĨā¤¨āĨā¤šāĨ‹ā¤¸āĨāĨ¤"},loadingMore:function(){return"⤅⤰āĨ ⤍⤤ā¤ŋā¤œā¤žā¤šā¤°āĨ ⤭⤰ā¤ŋ⤁ā¤ĻāĨˆā¤›ā¤¨āĨ â€Ļ"},maximumSelected:function(n){var e="⤤⤁ā¤Ēā¤žā¤ˆ "+n.maximum+" ā¤ĩ⤏āĨā¤¤āĨ ā¤Žā¤žā¤¤āĨā¤° ā¤›ā¤žā¤¨āĨā¤¨ ā¤Ēā¤žā¤‰ā¤ā¤¨āĨā¤šāĨā¤¨āĨā¤›āĨ¤";return 1!=n.maximum&&(e="⤤⤁ā¤Ēā¤žā¤ˆ "+n.maximum+" ā¤ĩ⤏āĨā¤¤āĨā¤šā¤°āĨ ā¤Žā¤žā¤¤āĨā¤° ā¤›ā¤žā¤¨āĨā¤¨ ā¤Ēā¤žā¤‰ā¤ā¤¨āĨā¤šāĨā¤¨āĨā¤›āĨ¤"),e},noResults:function(){return"⤕āĨā¤¨āĨˆ ā¤Ē⤍ā¤ŋ ⤍⤤ā¤ŋā¤œā¤ž ⤭āĨ‡ā¤Ÿā¤ŋā¤ā¤¨āĨ¤"},searching:function(){return"⤖āĨ‹ā¤œā¤ŋ ā¤šāĨā¤ā¤ĻāĨˆā¤›â€Ļ"}}}),n.define,n.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/nl.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/nl.js similarity index 92% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/nl.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/nl.js index 776c2df6a..2b74058d2 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/nl.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/nl.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var e=jQuery.fn.select2.amd;e.define("select2/i18n/nl",[],function(){return{errorLoading:function(){return"De resultaten konden niet worden geladen."},inputTooLong:function(e){return"Gelieve "+(e.input.length-e.maximum)+" karakters te verwijderen"},inputTooShort:function(e){return"Gelieve "+(e.minimum-e.input.length)+" of meer karakters in te voeren"},loadingMore:function(){return"Meer resultaten ladenâ€Ļ"},maximumSelected:function(e){var n=1==e.maximum?"kan":"kunnen",r="Er "+n+" maar "+e.maximum+" item";return 1!=e.maximum&&(r+="s"),r+=" worden geselecteerd"},noResults:function(){return"Geen resultaten gevondenâ€Ļ"},searching:function(){return"Zoekenâ€Ļ"},removeAllItems:function(){return"Verwijder alle items"}}}),e.define,e.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/pl.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/pl.js similarity index 93% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/pl.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/pl.js index 7790a50c3..4ca5748c3 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/pl.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/pl.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var n=jQuery.fn.select2.amd;n.define("select2/i18n/pl",[],function(){var n=["znak","znaki","znakÃŗw"],e=["element","elementy","elementÃŗw"],r=function(n,e){return 1===n?e[0]:n>1&&n<=4?e[1]:n>=5?e[2]:void 0};return{errorLoading:function(){return"Nie moÅŧna załadować wynikÃŗw."},inputTooLong:function(e){var t=e.input.length-e.maximum;return"Usuń "+t+" "+r(t,n)},inputTooShort:function(e){var t=e.minimum-e.input.length;return"Podaj przynajmniej "+t+" "+r(t,n)},loadingMore:function(){return"Trwa ładowanieâ€Ļ"},maximumSelected:function(n){return"MoÅŧesz zaznaczyć tylko "+n.maximum+" "+r(n.maximum,e)},noResults:function(){return"Brak wynikÃŗw"},searching:function(){return"Trwa wyszukiwanieâ€Ļ"},removeAllItems:function(){return"Usuń wszystkie przedmioty"}}}),n.define,n.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/ps.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/ps.js similarity index 93% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/ps.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/ps.js index 9d2cd8ca9..9b008e4c1 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/ps.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/ps.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var n=jQuery.fn.select2.amd;n.define("select2/i18n/ps",[],function(){return{errorLoading:function(){return"ŲžØ§ŲŠŲ„ŲŠ Ų†Ų‡ ØŗŲŠ ØĒØąŲ„Ø§ØŗŲ‡ ڊېدای"},inputTooLong:function(n){var e=n.input.length-n.maximum,r="د Ų…Ų‡ØąØ¨Ø§Ų†Û Ų„Ų…ØŽŲŠ "+e+" ØĒŲˆØąÛŒ Ú“Ų†ÚĢ ÚŠÚ“ØĻ";return 1!=e&&(r=r.replace("ØĒŲˆØąÛŒ","ØĒŲˆØąŲŠ")),r},inputTooShort:function(n){return"Ų„Ú– ØĒØą Ų„Ú–Ų‡ "+(n.minimum-n.input.length)+" ŲŠØ§ Ú‰ÛØą ØĒŲˆØąŲŠ ŲˆŲ„ŲŠÚŠØĻ"},loadingMore:function(){return"Ų†ŲˆØąŲŠ ŲžØ§ŲŠŲ„ŲŠ ØĒØąŲ„Ø§ØŗŲ‡ ÚŠŲŠÚ–ŲŠ..."},maximumSelected:function(n){var e="ØĒØ§ØŗŲˆ ŲŠŲˆØ§Ø˛ŲŠ "+n.maximum+" Ų‚Ų„Ų… ŲžŲ‡ Ų†ÚšŲ‡ ÚŠŲˆŲ„Ø§ÛŒ ØŗÛŒ";return 1!=n.maximum&&(e=e.replace("Ų‚Ų„Ų…","Ų‚Ų„Ų…ŲˆŲ†Ų‡")),e},noResults:function(){return"ŲžØ§ŲŠŲ„ŲŠ ؈ Ų†Ų‡ Ų…ŲˆŲ†Ø¯Ų„ ØŗŲˆÛ"},searching:function(){return"Ų„ŲŧŲˆŲ„ ÚŠŲŠÚ–ŲŠ..."},removeAllItems:function(){return"ŲŧŲˆŲ„ ØĒŲˆÚŠŲŠ Ų„ØąÛ کړØĻ"}}}),n.define,n.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/pt-BR.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/pt-BR.js similarity index 92% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/pt-BR.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/pt-BR.js index f26c8133f..c991e2550 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/pt-BR.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/pt-BR.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var e=jQuery.fn.select2.amd;e.define("select2/i18n/pt-BR",[],function(){return{errorLoading:function(){return"Os resultados nÃŖo puderam ser carregados."},inputTooLong:function(e){var n=e.input.length-e.maximum,r="Apague "+n+" caracter";return 1!=n&&(r+="es"),r},inputTooShort:function(e){return"Digite "+(e.minimum-e.input.length)+" ou mais caracteres"},loadingMore:function(){return"Carregando mais resultadosâ€Ļ"},maximumSelected:function(e){var n="VocÃĒ sÃŗ pode selecionar "+e.maximum+" ite";return 1==e.maximum?n+="m":n+="ns",n},noResults:function(){return"Nenhum resultado encontrado"},searching:function(){return"Buscandoâ€Ļ"},removeAllItems:function(){return"Remover todos os itens"}}}),e.define,e.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/pt.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/pt.js similarity index 92% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/pt.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/pt.js index 2068a7c28..b5da1a6b4 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/pt.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/pt.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var e=jQuery.fn.select2.amd;e.define("select2/i18n/pt",[],function(){return{errorLoading:function(){return"Os resultados nÃŖo puderam ser carregados."},inputTooLong:function(e){var r=e.input.length-e.maximum,n="Por favor apague "+r+" ";return n+=1!=r?"caracteres":"caractere"},inputTooShort:function(e){return"Introduza "+(e.minimum-e.input.length)+" ou mais caracteres"},loadingMore:function(){return"A carregar mais resultadosâ€Ļ"},maximumSelected:function(e){var r="Apenas pode seleccionar "+e.maximum+" ";return r+=1!=e.maximum?"itens":"item"},noResults:function(){return"Sem resultados"},searching:function(){return"A procurarâ€Ļ"},removeAllItems:function(){return"Remover todos os itens"}}}),e.define,e.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/ro.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/ro.js similarity index 93% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/ro.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/ro.js index 4bff1c64b..1ba7b40be 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/ro.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/ro.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var e=jQuery.fn.select2.amd;e.define("select2/i18n/ro",[],function(){return{errorLoading:function(){return"Rezultatele nu au putut fi incărcate."},inputTooLong:function(e){var t=e.input.length-e.maximum,n="Vă rugăm să ștergeți"+t+" caracter";return 1!==t&&(n+="e"),n},inputTooShort:function(e){return"Vă rugăm să introduceți "+(e.minimum-e.input.length)+" sau mai multe caractere"},loadingMore:function(){return"Se ÃŽncarcă mai multe rezultateâ€Ļ"},maximumSelected:function(e){var t="Aveți voie să selectați cel mult "+e.maximum;return t+=" element",1!==e.maximum&&(t+="e"),t},noResults:function(){return"Nu au fost găsite rezultate"},searching:function(){return"Căutareâ€Ļ"},removeAllItems:function(){return"Eliminați toate elementele"}}}),e.define,e.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/ru.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/ru.js similarity index 94% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/ru.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/ru.js index 267b995ff..63a7d66c3 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/ru.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/ru.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var n=jQuery.fn.select2.amd;n.define("select2/i18n/ru",[],function(){function n(n,e,r,u){return n%10<5&&n%10>0&&n%100<5||n%100>20?n%10>1?r:e:u}return{errorLoading:function(){return"НĐĩвОСĐŧĐžĐļĐŊĐž ĐˇĐ°ĐŗŅ€ŅƒĐˇĐ¸Ņ‚ŅŒ Ņ€ĐĩĐˇŅƒĐģŅŒŅ‚Đ°Ņ‚Ņ‹"},inputTooLong:function(e){var r=e.input.length-e.maximum,u="ПоĐļаĐģŅƒĐšŅŅ‚Đ°, ввĐĩĐ´Đ¸Ņ‚Đĩ ĐŊа "+r+" ŅĐ¸ĐŧвОĐģ";return u+=n(r,"","a","Ов"),u+=" ĐŧĐĩĐŊҌ҈Đĩ"},inputTooShort:function(e){var r=e.minimum-e.input.length,u="ПоĐļаĐģŅƒĐšŅŅ‚Đ°, ввĐĩĐ´Đ¸Ņ‚Đĩ Đĩ҉ґ Ņ…ĐžŅ‚Ņ ĐąŅ‹ "+r+" ŅĐ¸ĐŧвОĐģ";return u+=n(r,"","a","Ов")},loadingMore:function(){return"Đ—Đ°ĐŗŅ€ŅƒĐˇĐēа даĐŊĐŊҋ҅â€Ļ"},maximumSelected:function(e){var r="Đ’Ņ‹ ĐŧĐžĐļĐĩŅ‚Đĩ Đ˛Ņ‹ĐąŅ€Đ°Ņ‚ŅŒ ĐŊĐĩ йОĐģĐĩĐĩ "+e.maximum+" ŅĐģĐĩĐŧĐĩĐŊŅ‚";return r+=n(e.maximum,"","a","Ов")},noResults:function(){return"ХОвĐŋадĐĩĐŊиК ĐŊĐĩ ĐŊаКдĐĩĐŊĐž"},searching:function(){return"ĐŸĐžĐ¸ŅĐēâ€Ļ"},removeAllItems:function(){return"ĐŖĐ´Đ°ĐģĐ¸Ņ‚ŅŒ Đ˛ŅĐĩ ŅĐģĐĩĐŧĐĩĐŊ҂ҋ"}}}),n.define,n.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/sk.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/sk.js similarity index 95% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/sk.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/sk.js index 64346a7a3..5049528ad 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/sk.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/sk.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var e=jQuery.fn.select2.amd;e.define("select2/i18n/sk",[],function(){var e={2:function(e){return e?"dva":"dve"},3:function(){return"tri"},4:function(){return"ÅĄtyri"}};return{errorLoading:function(){return"VÃŊsledky sa nepodarilo načítaÅĨ."},inputTooLong:function(n){var t=n.input.length-n.maximum;return 1==t?"Prosím, zadajte o jeden znak menej":t>=2&&t<=4?"Prosím, zadajte o "+e[t](!0)+" znaky menej":"Prosím, zadajte o "+t+" znakov menej"},inputTooShort:function(n){var t=n.minimum-n.input.length;return 1==t?"Prosím, zadajte eÅĄte jeden znak":t<=4?"Prosím, zadajte eÅĄte ďalÅĄie "+e[t](!0)+" znaky":"Prosím, zadajte eÅĄte ďalÅĄÃ­ch "+t+" znakov"},loadingMore:function(){return"Načítanie ďalÅĄÃ­ch vÃŊsledkovâ€Ļ"},maximumSelected:function(n){return 1==n.maximum?"MôŞete zvoliÅĨ len jednu poloÅžku":n.maximum>=2&&n.maximum<=4?"MôŞete zvoliÅĨ najviac "+e[n.maximum](!1)+" poloÅžky":"MôŞete zvoliÅĨ najviac "+n.maximum+" poloÅžiek"},noResults:function(){return"NenaÅĄli sa Åžiadne poloÅžky"},searching:function(){return"VyhÄžadÃĄvanieâ€Ļ"},removeAllItems:function(){return"OdstrÃĄÅˆte vÅĄetky poloÅžky"}}}),e.define,e.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/sl.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/sl.js similarity index 93% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/sl.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/sl.js index 9b26c39ad..4d0b7d3e3 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/sl.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/sl.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var e=jQuery.fn.select2.amd;e.define("select2/i18n/sl",[],function(){return{errorLoading:function(){return"Zadetkov iskanja ni bilo mogoče naloÅžiti."},inputTooLong:function(e){var n=e.input.length-e.maximum,t="Prosim zbriÅĄite "+n+" znak";return 2==n?t+="a":1!=n&&(t+="e"),t},inputTooShort:function(e){var n=e.minimum-e.input.length,t="Prosim vpiÅĄite ÅĄe "+n+" znak";return 2==n?t+="a":1!=n&&(t+="e"),t},loadingMore:function(){return"Nalagam več zadetkovâ€Ļ"},maximumSelected:function(e){var n="Označite lahko največ "+e.maximum+" predmet";return 2==e.maximum?n+="a":1!=e.maximum&&(n+="e"),n},noResults:function(){return"Ni zadetkov."},searching:function(){return"IÅĄÄemâ€Ļ"},removeAllItems:function(){return"Odstranite vse elemente"}}}),e.define,e.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/sq.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/sq.js similarity index 92% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/sq.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/sq.js index 4999d21f8..59162024e 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/sq.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/sq.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var e=jQuery.fn.select2.amd;e.define("select2/i18n/sq",[],function(){return{errorLoading:function(){return"Rezultatet nuk mund tÃĢ ngarkoheshin."},inputTooLong:function(e){var n=e.input.length-e.maximum,t="TÃĢ lutem fshi "+n+" karakter";return 1!=n&&(t+="e"),t},inputTooShort:function(e){return"TÃĢ lutem shkruaj "+(e.minimum-e.input.length)+" ose mÃĢ shumÃĢ karaktere"},loadingMore:function(){return"Duke ngarkuar mÃĢ shumÃĢ rezultateâ€Ļ"},maximumSelected:function(e){var n="Mund tÃĢ zgjedhÃĢsh vetÃĢm "+e.maximum+" element";return 1!=e.maximum&&(n+="e"),n},noResults:function(){return"Nuk u gjet asnjÃĢ rezultat"},searching:function(){return"Duke kÃĢrkuarâ€Ļ"},removeAllItems:function(){return"Hiq tÃĢ gjitha sendet"}}}),e.define,e.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/sr-Cyrl.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/sr-Cyrl.js similarity index 94% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/sr-Cyrl.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/sr-Cyrl.js index 34fb00599..ce13ce8f9 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/sr-Cyrl.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/sr-Cyrl.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var n=jQuery.fn.select2.amd;n.define("select2/i18n/sr-Cyrl",[],function(){function n(n,e,r,u){return n%10==1&&n%100!=11?e:n%10>=2&&n%10<=4&&(n%100<12||n%100>14)?r:u}return{errorLoading:function(){return"ĐŸŅ€ĐĩŅƒĐˇĐ¸ĐŧĐ°ŅšĐĩ ĐŊĐ¸Ņ˜Đĩ ҃ҁĐŋĐĩĐģĐž."},inputTooLong:function(e){var r=e.input.length-e.maximum,u="ĐžĐąŅ€Đ¸ŅˆĐ¸Ņ‚Đĩ "+r+" ŅĐ¸ĐŧйОĐģ";return u+=n(r,"","а","а")},inputTooShort:function(e){var r=e.minimum-e.input.length,u="ĐŖĐēŅƒŅ†Đ°Ņ˜Ņ‚Đĩ ĐąĐ°Ņ€ Ņ˜ĐžŅˆ "+r+" ŅĐ¸ĐŧйОĐģ";return u+=n(r,"","а","а")},loadingMore:function(){return"ĐŸŅ€ĐĩŅƒĐˇĐ¸ĐŧĐ°ŅšĐĩ Ņ˜ĐžŅˆ Ņ€ĐĩĐˇŅƒĐģŅ‚Đ°Ņ‚Đ°â€Ļ"},maximumSelected:function(e){var r="МоĐļĐĩŅ‚Đĩ Đ¸ĐˇĐ°ĐąŅ€Đ°Ņ‚Đ¸ ŅĐ°ĐŧĐž "+e.maximum+" ŅŅ‚Đ°Đ˛Đē";return r+=n(e.maximum,"҃","Đĩ","и")},noResults:function(){return"ĐĐ¸ŅˆŅ‚Đ° ĐŊĐ¸Ņ˜Đĩ ĐŋŅ€ĐžĐŊĐ°Ņ’ĐĩĐŊĐž"},searching:function(){return"ĐŸŅ€ĐĩŅ‚Ņ€Đ°ĐŗĐ°â€Ļ"},removeAllItems:function(){return"ĐŖĐēĐģĐžĐŊĐ¸Ņ‚Đĩ ŅĐ˛Đĩ ŅŅ‚Đ°Đ˛ĐēĐĩ"}}}),n.define,n.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/sr.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/sr.js similarity index 93% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/sr.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/sr.js index 2293b5875..dd407a06d 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/sr.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/sr.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var n=jQuery.fn.select2.amd;n.define("select2/i18n/sr",[],function(){function n(n,e,r,t){return n%10==1&&n%100!=11?e:n%10>=2&&n%10<=4&&(n%100<12||n%100>14)?r:t}return{errorLoading:function(){return"Preuzimanje nije uspelo."},inputTooLong:function(e){var r=e.input.length-e.maximum,t="ObriÅĄite "+r+" simbol";return t+=n(r,"","a","a")},inputTooShort:function(e){var r=e.minimum-e.input.length,t="Ukucajte bar joÅĄ "+r+" simbol";return t+=n(r,"","a","a")},loadingMore:function(){return"Preuzimanje joÅĄ rezultataâ€Ļ"},maximumSelected:function(e){var r="MoÅžete izabrati samo "+e.maximum+" stavk";return r+=n(e.maximum,"u","e","i")},noResults:function(){return"NiÅĄta nije pronađeno"},searching:function(){return"Pretragaâ€Ļ"},removeAllItems:function(){return"ĐŖĐēĐģĐžĐŊĐ¸Ņ‚Đĩ ŅĐ˛Đĩ ŅŅ‚Đ°Đ˛ĐēĐĩ"}}}),n.define,n.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/sv.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/sv.js similarity index 91% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/sv.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/sv.js index ab5cd50c3..1bc8724a7 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/sv.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/sv.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var n=jQuery.fn.select2.amd;n.define("select2/i18n/sv",[],function(){return{errorLoading:function(){return"Resultat kunde inte laddas."},inputTooLong:function(n){return"Vänligen sudda ut "+(n.input.length-n.maximum)+" tecken"},inputTooShort:function(n){return"Vänligen skriv in "+(n.minimum-n.input.length)+" eller fler tecken"},loadingMore:function(){return"Laddar fler resultatâ€Ļ"},maximumSelected:function(n){return"Du kan max välja "+n.maximum+" element"},noResults:function(){return"Inga träffar"},searching:function(){return"SÃļkerâ€Ļ"},removeAllItems:function(){return"Ta bort alla objekt"}}}),n.define,n.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/th.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/th.js similarity index 94% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/th.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/th.js index 5458cc09d..63eab7114 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/th.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/th.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var n=jQuery.fn.select2.amd;n.define("select2/i18n/th",[],function(){return{errorLoading:function(){return"āš„ā¸Ąāšˆā¸Ēā¸˛ā¸Ąā¸˛ā¸Ŗā¸–ā¸„āš‰ā¸™ā¸‚āš‰ā¸­ā¸Ąā¸šā¸Ĩāš„ā¸”āš‰"},inputTooLong:function(n){return"āš‚ā¸›ā¸Ŗā¸”ā¸Ĩ⏚⏭⏭⏁ "+(n.input.length-n.maximum)+" ā¸•ā¸ąā¸§ā¸­ā¸ąā¸ā¸Šā¸Ŗ"},inputTooShort:function(n){return"āš‚ā¸›ā¸Ŗā¸”ā¸žā¸´ā¸Ąā¸žāšŒāš€ā¸žā¸´āšˆā¸Ąā¸­ā¸ĩ⏁ "+(n.minimum-n.input.length)+" ā¸•ā¸ąā¸§ā¸­ā¸ąā¸ā¸Šā¸Ŗ"},loadingMore:function(){return"⏁⏺ā¸Ĩā¸ąā¸‡ā¸„āš‰ā¸™ā¸‚āš‰ā¸­ā¸Ąā¸šā¸Ĩāš€ā¸žā¸´āšˆā¸Ąâ€Ļ"},maximumSelected:function(n){return"⏄⏏⏓ā¸Ēā¸˛ā¸Ąā¸˛ā¸Ŗā¸–āš€ā¸Ĩā¸ˇā¸­ā¸āš„ā¸”āš‰āš„ā¸Ąāšˆāš€ā¸ā¸´ā¸™ "+n.maximum+" ⏪⏞ā¸ĸ⏁⏞⏪"},noResults:function(){return"āš„ā¸Ąāšˆā¸žā¸šā¸‚āš‰ā¸­ā¸Ąā¸šā¸Ĩ"},searching:function(){return"⏁⏺ā¸Ĩā¸ąā¸‡ā¸„āš‰ā¸™ā¸‚āš‰ā¸­ā¸Ąā¸šā¸Ĩâ€Ļ"},removeAllItems:function(){return"ā¸Ĩ⏚⏪⏞ā¸ĸā¸ā¸˛ā¸Ŗā¸—ā¸ąāš‰ā¸‡ā¸Ģā¸Ąā¸”"}}}),n.define,n.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/tk.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/tk.js similarity index 91% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/tk.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/tk.js index 2c8274d00..30255ff37 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/tk.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/tk.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var e=jQuery.fn.select2.amd;e.define("select2/i18n/tk",[],function(){return{errorLoading:function(){return"Netije ÃŊÃŧklenmedi."},inputTooLong:function(e){return e.input.length-e.maximum+" harp bozuň."},inputTooShort:function(e){return"Ýene-de iň az "+(e.minimum-e.input.length)+" harp ÃŊazyň."},loadingMore:function(){return"KÃļpräk netije gÃļrkezilÃŊärâ€Ļ"},maximumSelected:function(e){return"Diňe "+e.maximum+" sanysyny saÃŊlaň."},noResults:function(){return"Netije tapylmady."},searching:function(){return"GÃļzlenÃŊärâ€Ļ"},removeAllItems:function(){return"Remove all items"}}}),e.define,e.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/tr.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/tr.js similarity index 91% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/tr.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/tr.js index 5ab03b9be..fc4c0bf05 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/tr.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/tr.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var n=jQuery.fn.select2.amd;n.define("select2/i18n/tr",[],function(){return{errorLoading:function(){return"Sonuç yÃŧklenemedi"},inputTooLong:function(n){return n.input.length-n.maximum+" karakter daha girmelisiniz"},inputTooShort:function(n){return"En az "+(n.minimum-n.input.length)+" karakter daha girmelisiniz"},loadingMore:function(){return"Daha fazlaâ€Ļ"},maximumSelected:function(n){return"Sadece "+n.maximum+" seçim yapabilirsiniz"},noResults:function(){return"Sonuç bulunamadÄą"},searching:function(){return"AranÄąyorâ€Ļ"},removeAllItems:function(){return"TÃŧm Ãļğeleri kaldÄąr"}}}),n.define,n.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/uk.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/uk.js similarity index 94% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/uk.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/uk.js index cf3febb2b..63697e388 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/uk.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/uk.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var n=jQuery.fn.select2.amd;n.define("select2/i18n/uk",[],function(){function n(n,e,u,r){return n%100>10&&n%100<15?r:n%10==1?e:n%10>1&&n%10<5?u:r}return{errorLoading:function(){return"НĐĩĐŧĐžĐļĐģивО СаваĐŊŅ‚Đ°ĐļĐ¸Ņ‚Đ¸ Ņ€ĐĩĐˇŅƒĐģŅŒŅ‚Đ°Ņ‚Đ¸"},inputTooLong:function(e){return"Đ‘ŅƒĐ´ŅŒ ĐģĐ°ŅĐēа, видаĐģŅ–Ņ‚ŅŒ "+(e.input.length-e.maximum)+" "+n(e.maximum,"ĐģŅ–Ņ‚ĐĩŅ€Ņƒ","ĐģŅ–Ņ‚ĐĩŅ€Đ¸","ĐģŅ–Ņ‚ĐĩŅ€")},inputTooShort:function(n){return"Đ‘ŅƒĐ´ŅŒ ĐģĐ°ŅĐēа, ввĐĩĐ´Ņ–Ņ‚ŅŒ "+(n.minimum-n.input.length)+" айО ĐąŅ–ĐģҌ҈Đĩ ĐģŅ–Ņ‚ĐĩŅ€"},loadingMore:function(){return"ЗаваĐŊŅ‚Đ°ĐļĐĩĐŊĐŊŅ Ņ–ĐŊŅˆĐ¸Ņ… Ņ€ĐĩĐˇŅƒĐģŅŒŅ‚Đ°Ņ‚Ņ–Đ˛â€Ļ"},maximumSelected:function(e){return"Ви ĐŧĐžĐļĐĩŅ‚Đĩ Đ˛Đ¸ĐąŅ€Đ°Ņ‚Đ¸ ĐģĐ¸ŅˆĐĩ "+e.maximum+" "+n(e.maximum,"Đŋ҃ĐŊĐēŅ‚","Đŋ҃ĐŊĐēŅ‚Đ¸","Đŋ҃ĐŊĐēŅ‚Ņ–Đ˛")},noResults:function(){return"ĐŅ–Ņ‡ĐžĐŗĐž ĐŊĐĩ СĐŊаКдĐĩĐŊĐž"},searching:function(){return"ĐŸĐžŅˆŅƒĐēâ€Ļ"},removeAllItems:function(){return"ВидаĐģĐ¸Ņ‚Đ¸ Đ˛ŅŅ– ĐĩĐģĐĩĐŧĐĩĐŊŅ‚Đ¸"}}}),n.define,n.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/vi.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/vi.js similarity index 91% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/vi.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/vi.js index 90848f324..24f3bc2d6 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/vi.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/vi.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var n=jQuery.fn.select2.amd;n.define("select2/i18n/vi",[],function(){return{inputTooLong:function(n){return"Vui lÃ˛ng xÃŗa báģ›t "+(n.input.length-n.maximum)+" kÃŊ táģą"},inputTooShort:function(n){return"Vui lÃ˛ng nháē­p thÃĒm táģĢ "+(n.minimum-n.input.length)+" kÃŊ táģą tráģŸ lÃĒn"},loadingMore:function(){return"Đang láēĨy thÃĒm káēŋt quáēŖâ€Ļ"},maximumSelected:function(n){return"Cháģ‰ cÃŗ tháģƒ cháģn đưáģŖc "+n.maximum+" láģąa cháģn"},noResults:function(){return"Không tÃŦm tháēĨy káēŋt quáēŖ"},searching:function(){return"Đang tÃŦmâ€Ļ"},removeAllItems:function(){return"XÃŗa táēĨt cáēŖ cÃĄc máģĨc"}}}),n.define,n.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/zh-CN.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/zh-CN.js similarity index 91% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/zh-CN.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/zh-CN.js index 4b98e42e9..2c5649d31 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/zh-CN.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/zh-CN.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var n=jQuery.fn.select2.amd;n.define("select2/i18n/zh-CN",[],function(){return{errorLoading:function(){return"æ— æŗ•čŊŊå…Ĩį쓿žœã€‚"},inputTooLong:function(n){return"č¯ˇåˆ é™¤"+(n.input.length-n.maximum)+"ä¸Ē字įŦĻ"},inputTooShort:function(n){return"č¯ˇå†čž“å…Ĩ臺少"+(n.minimum-n.input.length)+"ä¸Ē字įŦĻ"},loadingMore:function(){return"čŊŊå…Ĩ更多į쓿žœâ€Ļ"},maximumSelected:function(n){return"最多åĒčƒŊ选拊"+n.maximum+"ä¸ĒéĄšį›Ž"},noResults:function(){return"æœĒ扞到į쓿žœ"},searching:function(){return"搜į´ĸ中â€Ļ"},removeAllItems:function(){return"åˆ é™¤æ‰€æœ‰éĄšį›Ž"}}}),n.define,n.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/i18n/zh-TW.js b/netbox/project-static/select2-4.0.13/dist/js/i18n/zh-TW.js similarity index 90% rename from netbox/project-static/select2-4.0.12/dist/js/i18n/zh-TW.js rename to netbox/project-static/select2-4.0.13/dist/js/i18n/zh-TW.js index 39b1a4e85..570a56693 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/i18n/zh-TW.js +++ b/netbox/project-static/select2-4.0.13/dist/js/i18n/zh-TW.js @@ -1,3 +1,3 @@ -/*! Select2 4.0.12 | https://github.com/select2/select2/blob/master/LICENSE.md */ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ !function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var n=jQuery.fn.select2.amd;n.define("select2/i18n/zh-TW",[],function(){return{inputTooLong:function(n){return"čĢ‹åˆĒ掉"+(n.input.length-n.maximum)+"個字元"},inputTooShort:function(n){return"čĢ‹å†čŧ¸å…Ĩ"+(n.minimum-n.input.length)+"個字元"},loadingMore:function(){return"čŧ‰å…Ĩ中â€Ļ"},maximumSelected:function(n){return"äŊ åĒčƒŊ選擇最多"+n.maximum+"項"},noResults:function(){return"æ˛’æœ‰æ‰žåˆ°į›¸įŦĻįš„é …į›Ž"},searching:function(){return"搜尋中â€Ļ"},removeAllItems:function(){return"åˆĒé™¤æ‰€æœ‰é …į›Ž"}}}),n.define,n.require}(); \ No newline at end of file diff --git a/netbox/project-static/select2-4.0.12/dist/js/select2.full.js b/netbox/project-static/select2-4.0.13/dist/js/select2.full.js similarity index 98% rename from netbox/project-static/select2-4.0.12/dist/js/select2.full.js rename to netbox/project-static/select2-4.0.13/dist/js/select2.full.js index 53b326b6a..358572a65 100644 --- a/netbox/project-static/select2-4.0.12/dist/js/select2.full.js +++ b/netbox/project-static/select2-4.0.13/dist/js/select2.full.js @@ -1,5 +1,5 @@ /*! - * Select2 4.0.12 + * Select2 4.0.13 * https://select2.github.io * * Released under the MIT license @@ -1556,6 +1556,27 @@ S2.define('select2/selection/base',[ throw new Error('The `update` method must be defined in child classes.'); }; + /** + * Helper method to abstract the "enabled" (not "disabled") state of this + * object. + * + * @return {true} if the instance is not disabled. + * @return {false} if the instance is disabled. + */ + BaseSelection.prototype.isEnabled = function () { + return !this.isDisabled(); + }; + + /** + * Helper method to abstract the "disabled" state of this object. + * + * @return {true} if the disabled option is true. + * @return {false} if the disabled option is false. + */ + BaseSelection.prototype.isDisabled = function () { + return this.options.get('disabled'); + }; + return BaseSelection; }); @@ -1706,7 +1727,7 @@ S2.define('select2/selection/multiple',[ '.select2-selection__choice__remove', function (evt) { // Ignore the event if it is disabled - if (self.options.get('disabled')) { + if (self.isDisabled()) { return; } @@ -1867,7 +1888,7 @@ S2.define('select2/selection/allowClear',[ AllowClear.prototype._handleClear = function (_, evt) { // Ignore the event if it is disabled - if (this.options.get('disabled')) { + if (this.isDisabled()) { return; } @@ -1910,7 +1931,7 @@ S2.define('select2/selection/allowClear',[ } } - this.$element.trigger('change'); + this.$element.trigger('input').trigger('change'); this.trigger('toggle', {}); }; @@ -1933,7 +1954,7 @@ S2.define('select2/selection/allowClear',[ return; } - var removeAll = this.options.get('translations').get('removeAllItems'); + var removeAll = this.options.get('translations').get('removeAllItems'); var $remove = $( '' + @@ -3201,7 +3222,7 @@ S2.define('select2/data/select',[ if ($(data.element).is('option')) { data.element.selected = true; - this.$element.trigger('change'); + this.$element.trigger('input').trigger('change'); return; } @@ -3222,13 +3243,13 @@ S2.define('select2/data/select',[ } self.$element.val(val); - self.$element.trigger('change'); + self.$element.trigger('input').trigger('change'); }); } else { var val = data.id; this.$element.val(val); - this.$element.trigger('change'); + this.$element.trigger('input').trigger('change'); } }; @@ -3244,7 +3265,7 @@ S2.define('select2/data/select',[ if ($(data.element).is('option')) { data.element.selected = false; - this.$element.trigger('change'); + this.$element.trigger('input').trigger('change'); return; } @@ -3262,7 +3283,7 @@ S2.define('select2/data/select',[ self.$element.val(val); - self.$element.trigger('change'); + self.$element.trigger('input').trigger('change'); }); }; @@ -5545,8 +5566,8 @@ S2.define('select2/core',[ if (observer != null) { this._observer = new observer(function (mutations) { - $.each(mutations, self._syncA); - $.each(mutations, self._syncS); + self._syncA(); + self._syncS(null, mutations); }); this._observer.observe(this.$element[0], { attributes: true, @@ -5668,7 +5689,7 @@ S2.define('select2/core',[ if (self.isOpen()) { if (key === KEYS.ESC || key === KEYS.TAB || (key === KEYS.UP && evt.altKey)) { - self.close(); + self.close(evt); evt.preventDefault(); } else if (key === KEYS.ENTER) { @@ -5702,7 +5723,7 @@ S2.define('select2/core',[ Select2.prototype._syncAttributes = function () { this.options.set('disabled', this.$element.prop('disabled')); - if (this.options.get('disabled')) { + if (this.isDisabled()) { if (this.isOpen()) { this.close(); } @@ -5713,7 +5734,7 @@ S2.define('select2/core',[ } }; - Select2.prototype._syncSubtree = function (evt, mutations) { + Select2.prototype._isChangeMutation = function (evt, mutations) { var changed = false; var self = this; @@ -5741,7 +5762,22 @@ S2.define('select2/core',[ } } else if (mutations.removedNodes && mutations.removedNodes.length > 0) { changed = true; + } else if ($.isArray(mutations)) { + $.each(mutations, function(evt, mutation) { + if (self._isChangeMutation(evt, mutation)) { + // We've found a change mutation. + // Let's escape from the loop and continue + changed = true; + return false; + } + }); } + return changed; + }; + + Select2.prototype._syncSubtree = function (evt, mutations) { + var changed = this._isChangeMutation(evt, mutations); + var self = this; // Only re-pull the data if we think there is a change if (changed) { @@ -5792,7 +5828,7 @@ S2.define('select2/core',[ }; Select2.prototype.toggleDropdown = function () { - if (this.options.get('disabled')) { + if (this.isDisabled()) { return; } @@ -5808,15 +5844,40 @@ S2.define('select2/core',[ return; } + if (this.isDisabled()) { + return; + } + this.trigger('query', {}); }; - Select2.prototype.close = function () { + Select2.prototype.close = function (evt) { if (!this.isOpen()) { return; } - this.trigger('close', {}); + this.trigger('close', { originalEvent : evt }); + }; + + /** + * Helper method to abstract the "enabled" (not "disabled") state of this + * object. + * + * @return {true} if the instance is not disabled. + * @return {false} if the instance is disabled. + */ + Select2.prototype.isEnabled = function () { + return !this.isDisabled(); + }; + + /** + * Helper method to abstract the "disabled" state of this object. + * + * @return {true} if the disabled option is true. + * @return {false} if the disabled option is false. + */ + Select2.prototype.isDisabled = function () { + return this.options.get('disabled'); }; Select2.prototype.isOpen = function () { @@ -5893,7 +5954,7 @@ S2.define('select2/core',[ }); } - this.$element.val(newVal).trigger('change'); + this.$element.val(newVal).trigger('input').trigger('change'); }; Select2.prototype.destroy = function () { @@ -6228,13 +6289,13 @@ S2.define('select2/compat/inputData',[ }); this.$element.val(data.id); - this.$element.trigger('change'); + this.$element.trigger('input').trigger('change'); } else { var value = this.$element.val(); value += this._valueSeparator + data.id; this.$element.val(value); - this.$element.trigger('change'); + this.$element.trigger('input').trigger('change'); } }; @@ -6257,7 +6318,7 @@ S2.define('select2/compat/inputData',[ } self.$element.val(values.join(self._valueSeparator)); - self.$element.trigger('change'); + self.$element.trigger('input').trigger('change'); }); }; diff --git a/netbox/project-static/select2-4.0.13/dist/js/select2.full.min.js b/netbox/project-static/select2-4.0.13/dist/js/select2.full.min.js new file mode 100644 index 000000000..fa781916e --- /dev/null +++ b/netbox/project-static/select2-4.0.13/dist/js/select2.full.min.js @@ -0,0 +1,2 @@ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ +!function(n){"function"==typeof define&&define.amd?define(["jquery"],n):"object"==typeof module&&module.exports?module.exports=function(e,t){return void 0===t&&(t="undefined"!=typeof window?require("jquery"):require("jquery")(e)),n(t),t}:n(jQuery)}(function(d){var e=function(){if(d&&d.fn&&d.fn.select2&&d.fn.select2.amd)var e=d.fn.select2.amd;var t,n,i,h,o,s,f,g,m,v,y,_,r,a,w,l;function b(e,t){return r.call(e,t)}function c(e,t){var n,i,r,o,s,a,l,c,u,d,p,h=t&&t.split("/"),f=y.map,g=f&&f["*"]||{};if(e){for(s=(e=e.split("/")).length-1,y.nodeIdCompat&&w.test(e[s])&&(e[s]=e[s].replace(w,"")),"."===e[0].charAt(0)&&h&&(e=h.slice(0,h.length-1).concat(e)),u=0;u":">",'"':""","'":"'","/":"/"};return"string"!=typeof e?e:String(e).replace(/[&<>"'\/\\]/g,function(e){return t[e]})},r.appendMany=function(e,t){if("1.7"===o.fn.jquery.substr(0,3)){var n=o();o.map(t,function(e){n=n.add(e)}),t=n}e.append(t)},r.__cache={};var n=0;return r.GetUniqueElementId=function(e){var t=e.getAttribute("data-select2-id");return null==t&&(e.id?(t=e.id,e.setAttribute("data-select2-id",t)):(e.setAttribute("data-select2-id",++n),t=n.toString())),t},r.StoreData=function(e,t,n){var i=r.GetUniqueElementId(e);r.__cache[i]||(r.__cache[i]={}),r.__cache[i][t]=n},r.GetData=function(e,t){var n=r.GetUniqueElementId(e);return t?r.__cache[n]&&null!=r.__cache[n][t]?r.__cache[n][t]:o(e).data(t):r.__cache[n]},r.RemoveData=function(e){var t=r.GetUniqueElementId(e);null!=r.__cache[t]&&delete r.__cache[t],e.removeAttribute("data-select2-id")},r}),e.define("select2/results",["jquery","./utils"],function(h,f){function i(e,t,n){this.$element=e,this.data=n,this.options=t,i.__super__.constructor.call(this)}return f.Extend(i,f.Observable),i.prototype.render=function(){var e=h('
      ');return this.options.get("multiple")&&e.attr("aria-multiselectable","true"),this.$results=e},i.prototype.clear=function(){this.$results.empty()},i.prototype.displayMessage=function(e){var t=this.options.get("escapeMarkup");this.clear(),this.hideLoading();var n=h(''),i=this.options.get("translations").get(e.message);n.append(t(i(e.args))),n[0].className+=" select2-results__message",this.$results.append(n)},i.prototype.hideMessages=function(){this.$results.find(".select2-results__message").remove()},i.prototype.append=function(e){this.hideLoading();var t=[];if(null!=e.results&&0!==e.results.length){e.results=this.sort(e.results);for(var n=0;n",{class:"select2-results__options select2-results__options--nested"});p.append(l),s.append(a),s.append(p)}else this.template(e,t);return f.StoreData(t,"data",e),t},i.prototype.bind=function(t,e){var l=this,n=t.id+"-results";this.$results.attr("id",n),t.on("results:all",function(e){l.clear(),l.append(e.data),t.isOpen()&&(l.setClasses(),l.highlightFirstItem())}),t.on("results:append",function(e){l.append(e.data),t.isOpen()&&l.setClasses()}),t.on("query",function(e){l.hideMessages(),l.showLoading(e)}),t.on("select",function(){t.isOpen()&&(l.setClasses(),l.options.get("scrollAfterSelect")&&l.highlightFirstItem())}),t.on("unselect",function(){t.isOpen()&&(l.setClasses(),l.options.get("scrollAfterSelect")&&l.highlightFirstItem())}),t.on("open",function(){l.$results.attr("aria-expanded","true"),l.$results.attr("aria-hidden","false"),l.setClasses(),l.ensureHighlightVisible()}),t.on("close",function(){l.$results.attr("aria-expanded","false"),l.$results.attr("aria-hidden","true"),l.$results.removeAttr("aria-activedescendant")}),t.on("results:toggle",function(){var e=l.getHighlightedResults();0!==e.length&&e.trigger("mouseup")}),t.on("results:select",function(){var e=l.getHighlightedResults();if(0!==e.length){var t=f.GetData(e[0],"data");"true"==e.attr("aria-selected")?l.trigger("close",{}):l.trigger("select",{data:t})}}),t.on("results:previous",function(){var e=l.getHighlightedResults(),t=l.$results.find("[aria-selected]"),n=t.index(e);if(!(n<=0)){var i=n-1;0===e.length&&(i=0);var r=t.eq(i);r.trigger("mouseenter");var o=l.$results.offset().top,s=r.offset().top,a=l.$results.scrollTop()+(s-o);0===i?l.$results.scrollTop(0):s-o<0&&l.$results.scrollTop(a)}}),t.on("results:next",function(){var e=l.getHighlightedResults(),t=l.$results.find("[aria-selected]"),n=t.index(e)+1;if(!(n>=t.length)){var i=t.eq(n);i.trigger("mouseenter");var r=l.$results.offset().top+l.$results.outerHeight(!1),o=i.offset().top+i.outerHeight(!1),s=l.$results.scrollTop()+o-r;0===n?l.$results.scrollTop(0):rthis.$results.outerHeight()||o<0)&&this.$results.scrollTop(r)}},i.prototype.template=function(e,t){var n=this.options.get("templateResult"),i=this.options.get("escapeMarkup"),r=n(e,t);null==r?t.style.display="none":"string"==typeof r?t.innerHTML=i(r):h(t).append(r)},i}),e.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}}),e.define("select2/selection/base",["jquery","../utils","../keys"],function(n,i,r){function o(e,t){this.$element=e,this.options=t,o.__super__.constructor.call(this)}return i.Extend(o,i.Observable),o.prototype.render=function(){var e=n('');return this._tabindex=0,null!=i.GetData(this.$element[0],"old-tabindex")?this._tabindex=i.GetData(this.$element[0],"old-tabindex"):null!=this.$element.attr("tabindex")&&(this._tabindex=this.$element.attr("tabindex")),e.attr("title",this.$element.attr("title")),e.attr("tabindex",this._tabindex),e.attr("aria-disabled","false"),this.$selection=e},o.prototype.bind=function(e,t){var n=this,i=e.id+"-results";this.container=e,this.$selection.on("focus",function(e){n.trigger("focus",e)}),this.$selection.on("blur",function(e){n._handleBlur(e)}),this.$selection.on("keydown",function(e){n.trigger("keypress",e),e.which===r.SPACE&&e.preventDefault()}),e.on("results:focus",function(e){n.$selection.attr("aria-activedescendant",e.data._resultId)}),e.on("selection:update",function(e){n.update(e.data)}),e.on("open",function(){n.$selection.attr("aria-expanded","true"),n.$selection.attr("aria-owns",i),n._attachCloseHandler(e)}),e.on("close",function(){n.$selection.attr("aria-expanded","false"),n.$selection.removeAttr("aria-activedescendant"),n.$selection.removeAttr("aria-owns"),n.$selection.trigger("focus"),n._detachCloseHandler(e)}),e.on("enable",function(){n.$selection.attr("tabindex",n._tabindex),n.$selection.attr("aria-disabled","false")}),e.on("disable",function(){n.$selection.attr("tabindex","-1"),n.$selection.attr("aria-disabled","true")})},o.prototype._handleBlur=function(e){var t=this;window.setTimeout(function(){document.activeElement==t.$selection[0]||n.contains(t.$selection[0],document.activeElement)||t.trigger("blur",e)},1)},o.prototype._attachCloseHandler=function(e){n(document.body).on("mousedown.select2."+e.id,function(e){var t=n(e.target).closest(".select2");n(".select2.select2-container--open").each(function(){this!=t[0]&&i.GetData(this,"element").select2("close")})})},o.prototype._detachCloseHandler=function(e){n(document.body).off("mousedown.select2."+e.id)},o.prototype.position=function(e,t){t.find(".selection").append(e)},o.prototype.destroy=function(){this._detachCloseHandler(this.container)},o.prototype.update=function(e){throw new Error("The `update` method must be defined in child classes.")},o.prototype.isEnabled=function(){return!this.isDisabled()},o.prototype.isDisabled=function(){return this.options.get("disabled")},o}),e.define("select2/selection/single",["jquery","./base","../utils","../keys"],function(e,t,n,i){function r(){r.__super__.constructor.apply(this,arguments)}return n.Extend(r,t),r.prototype.render=function(){var e=r.__super__.render.call(this);return e.addClass("select2-selection--single"),e.html(''),e},r.prototype.bind=function(t,e){var n=this;r.__super__.bind.apply(this,arguments);var i=t.id+"-container";this.$selection.find(".select2-selection__rendered").attr("id",i).attr("role","textbox").attr("aria-readonly","true"),this.$selection.attr("aria-labelledby",i),this.$selection.on("mousedown",function(e){1===e.which&&n.trigger("toggle",{originalEvent:e})}),this.$selection.on("focus",function(e){}),this.$selection.on("blur",function(e){}),t.on("focus",function(e){t.isOpen()||n.$selection.trigger("focus")})},r.prototype.clear=function(){var e=this.$selection.find(".select2-selection__rendered");e.empty(),e.removeAttr("title")},r.prototype.display=function(e,t){var n=this.options.get("templateSelection");return this.options.get("escapeMarkup")(n(e,t))},r.prototype.selectionContainer=function(){return e("")},r.prototype.update=function(e){if(0!==e.length){var t=e[0],n=this.$selection.find(".select2-selection__rendered"),i=this.display(t,n);n.empty().append(i);var r=t.title||t.text;r?n.attr("title",r):n.removeAttr("title")}else this.clear()},r}),e.define("select2/selection/multiple",["jquery","./base","../utils"],function(r,e,l){function n(e,t){n.__super__.constructor.apply(this,arguments)}return l.Extend(n,e),n.prototype.render=function(){var e=n.__super__.render.call(this);return e.addClass("select2-selection--multiple"),e.html('
        '),e},n.prototype.bind=function(e,t){var i=this;n.__super__.bind.apply(this,arguments),this.$selection.on("click",function(e){i.trigger("toggle",{originalEvent:e})}),this.$selection.on("click",".select2-selection__choice__remove",function(e){if(!i.isDisabled()){var t=r(this).parent(),n=l.GetData(t[0],"data");i.trigger("unselect",{originalEvent:e,data:n})}})},n.prototype.clear=function(){var e=this.$selection.find(".select2-selection__rendered");e.empty(),e.removeAttr("title")},n.prototype.display=function(e,t){var n=this.options.get("templateSelection");return this.options.get("escapeMarkup")(n(e,t))},n.prototype.selectionContainer=function(){return r('
      • ×
      • ')},n.prototype.update=function(e){if(this.clear(),0!==e.length){for(var t=[],n=0;n×
        ');a.StoreData(i[0],"data",t),this.$selection.find(".select2-selection__rendered").prepend(i)}},e}),e.define("select2/selection/search",["jquery","../utils","../keys"],function(i,a,l){function e(e,t,n){e.call(this,t,n)}return e.prototype.render=function(e){var t=i('');this.$searchContainer=t,this.$search=t.find("input");var n=e.call(this);return this._transferTabIndex(),n},e.prototype.bind=function(e,t,n){var i=this,r=t.id+"-results";e.call(this,t,n),t.on("open",function(){i.$search.attr("aria-controls",r),i.$search.trigger("focus")}),t.on("close",function(){i.$search.val(""),i.$search.removeAttr("aria-controls"),i.$search.removeAttr("aria-activedescendant"),i.$search.trigger("focus")}),t.on("enable",function(){i.$search.prop("disabled",!1),i._transferTabIndex()}),t.on("disable",function(){i.$search.prop("disabled",!0)}),t.on("focus",function(e){i.$search.trigger("focus")}),t.on("results:focus",function(e){e.data._resultId?i.$search.attr("aria-activedescendant",e.data._resultId):i.$search.removeAttr("aria-activedescendant")}),this.$selection.on("focusin",".select2-search--inline",function(e){i.trigger("focus",e)}),this.$selection.on("focusout",".select2-search--inline",function(e){i._handleBlur(e)}),this.$selection.on("keydown",".select2-search--inline",function(e){if(e.stopPropagation(),i.trigger("keypress",e),i._keyUpPrevented=e.isDefaultPrevented(),e.which===l.BACKSPACE&&""===i.$search.val()){var t=i.$searchContainer.prev(".select2-selection__choice");if(0this.maximumInputLength?this.trigger("results:message",{message:"inputTooLong",args:{maximum:this.maximumInputLength,input:t.term,params:t}}):e.call(this,t,n)},e}),e.define("select2/data/maximumSelectionLength",[],function(){function e(e,t,n){this.maximumSelectionLength=n.get("maximumSelectionLength"),e.call(this,t,n)}return e.prototype.bind=function(e,t,n){var i=this;e.call(this,t,n),t.on("select",function(){i._checkIfMaximumSelected()})},e.prototype.query=function(e,t,n){var i=this;this._checkIfMaximumSelected(function(){e.call(i,t,n)})},e.prototype._checkIfMaximumSelected=function(e,n){var i=this;this.current(function(e){var t=null!=e?e.length:0;0=i.maximumSelectionLength?i.trigger("results:message",{message:"maximumSelected",args:{maximum:i.maximumSelectionLength}}):n&&n()})},e}),e.define("select2/dropdown",["jquery","./utils"],function(t,e){function n(e,t){this.$element=e,this.options=t,n.__super__.constructor.call(this)}return e.Extend(n,e.Observable),n.prototype.render=function(){var e=t('');return e.attr("dir",this.options.get("dir")),this.$dropdown=e},n.prototype.bind=function(){},n.prototype.position=function(e,t){},n.prototype.destroy=function(){this.$dropdown.remove()},n}),e.define("select2/dropdown/search",["jquery","../utils"],function(o,e){function t(){}return t.prototype.render=function(e){var t=e.call(this),n=o('');return this.$searchContainer=n,this.$search=n.find("input"),t.prepend(n),t},t.prototype.bind=function(e,t,n){var i=this,r=t.id+"-results";e.call(this,t,n),this.$search.on("keydown",function(e){i.trigger("keypress",e),i._keyUpPrevented=e.isDefaultPrevented()}),this.$search.on("input",function(e){o(this).off("keyup")}),this.$search.on("keyup input",function(e){i.handleSearch(e)}),t.on("open",function(){i.$search.attr("tabindex",0),i.$search.attr("aria-controls",r),i.$search.trigger("focus"),window.setTimeout(function(){i.$search.trigger("focus")},0)}),t.on("close",function(){i.$search.attr("tabindex",-1),i.$search.removeAttr("aria-controls"),i.$search.removeAttr("aria-activedescendant"),i.$search.val(""),i.$search.trigger("blur")}),t.on("focus",function(){t.isOpen()||i.$search.trigger("focus")}),t.on("results:all",function(e){null!=e.query.term&&""!==e.query.term||(i.showSearch(e)?i.$searchContainer.removeClass("select2-search--hide"):i.$searchContainer.addClass("select2-search--hide"))}),t.on("results:focus",function(e){e.data._resultId?i.$search.attr("aria-activedescendant",e.data._resultId):i.$search.removeAttr("aria-activedescendant")})},t.prototype.handleSearch=function(e){if(!this._keyUpPrevented){var t=this.$search.val();this.trigger("query",{term:t})}this._keyUpPrevented=!1},t.prototype.showSearch=function(e,t){return!0},t}),e.define("select2/dropdown/hidePlaceholder",[],function(){function e(e,t,n,i){this.placeholder=this.normalizePlaceholder(n.get("placeholder")),e.call(this,t,n,i)}return e.prototype.append=function(e,t){t.results=this.removePlaceholder(t.results),e.call(this,t)},e.prototype.normalizePlaceholder=function(e,t){return"string"==typeof t&&(t={id:"",text:t}),t},e.prototype.removePlaceholder=function(e,t){for(var n=t.slice(0),i=t.length-1;0<=i;i--){var r=t[i];this.placeholder.id===r.id&&n.splice(i,1)}return n},e}),e.define("select2/dropdown/infiniteScroll",["jquery"],function(n){function e(e,t,n,i){this.lastParams={},e.call(this,t,n,i),this.$loadingMore=this.createLoadingMore(),this.loading=!1}return e.prototype.append=function(e,t){this.$loadingMore.remove(),this.loading=!1,e.call(this,t),this.showLoadingMore(t)&&(this.$results.append(this.$loadingMore),this.loadMoreIfNeeded())},e.prototype.bind=function(e,t,n){var i=this;e.call(this,t,n),t.on("query",function(e){i.lastParams=e,i.loading=!0}),t.on("query:append",function(e){i.lastParams=e,i.loading=!0}),this.$results.on("scroll",this.loadMoreIfNeeded.bind(this))},e.prototype.loadMoreIfNeeded=function(){var e=n.contains(document.documentElement,this.$loadingMore[0]);if(!this.loading&&e){var t=this.$results.offset().top+this.$results.outerHeight(!1);this.$loadingMore.offset().top+this.$loadingMore.outerHeight(!1)<=t+50&&this.loadMore()}},e.prototype.loadMore=function(){this.loading=!0;var e=n.extend({},{page:1},this.lastParams);e.page++,this.trigger("query:append",e)},e.prototype.showLoadingMore=function(e,t){return t.pagination&&t.pagination.more},e.prototype.createLoadingMore=function(){var e=n('
      • '),t=this.options.get("translations").get("loadingMore");return e.html(t(this.lastParams)),e},e}),e.define("select2/dropdown/attachBody",["jquery","../utils"],function(f,a){function e(e,t,n){this.$dropdownParent=f(n.get("dropdownParent")||document.body),e.call(this,t,n)}return e.prototype.bind=function(e,t,n){var i=this;e.call(this,t,n),t.on("open",function(){i._showDropdown(),i._attachPositioningHandler(t),i._bindContainerResultHandlers(t)}),t.on("close",function(){i._hideDropdown(),i._detachPositioningHandler(t)}),this.$dropdownContainer.on("mousedown",function(e){e.stopPropagation()})},e.prototype.destroy=function(e){e.call(this),this.$dropdownContainer.remove()},e.prototype.position=function(e,t,n){t.attr("class",n.attr("class")),t.removeClass("select2"),t.addClass("select2-container--open"),t.css({position:"absolute",top:-999999}),this.$container=n},e.prototype.render=function(e){var t=f(""),n=e.call(this);return t.append(n),this.$dropdownContainer=t},e.prototype._hideDropdown=function(e){this.$dropdownContainer.detach()},e.prototype._bindContainerResultHandlers=function(e,t){if(!this._containerResultsHandlersBound){var n=this;t.on("results:all",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("results:append",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("results:message",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("select",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("unselect",function(){n._positionDropdown(),n._resizeDropdown()}),this._containerResultsHandlersBound=!0}},e.prototype._attachPositioningHandler=function(e,t){var n=this,i="scroll.select2."+t.id,r="resize.select2."+t.id,o="orientationchange.select2."+t.id,s=this.$container.parents().filter(a.hasScroll);s.each(function(){a.StoreData(this,"select2-scroll-position",{x:f(this).scrollLeft(),y:f(this).scrollTop()})}),s.on(i,function(e){var t=a.GetData(this,"select2-scroll-position");f(this).scrollTop(t.y)}),f(window).on(i+" "+r+" "+o,function(e){n._positionDropdown(),n._resizeDropdown()})},e.prototype._detachPositioningHandler=function(e,t){var n="scroll.select2."+t.id,i="resize.select2."+t.id,r="orientationchange.select2."+t.id;this.$container.parents().filter(a.hasScroll).off(n),f(window).off(n+" "+i+" "+r)},e.prototype._positionDropdown=function(){var e=f(window),t=this.$dropdown.hasClass("select2-dropdown--above"),n=this.$dropdown.hasClass("select2-dropdown--below"),i=null,r=this.$container.offset();r.bottom=r.top+this.$container.outerHeight(!1);var o={height:this.$container.outerHeight(!1)};o.top=r.top,o.bottom=r.top+o.height;var s=this.$dropdown.outerHeight(!1),a=e.scrollTop(),l=e.scrollTop()+e.height(),c=ar.bottom+s,d={left:r.left,top:o.bottom},p=this.$dropdownParent;"static"===p.css("position")&&(p=p.offsetParent());var h={top:0,left:0};(f.contains(document.body,p[0])||p[0].isConnected)&&(h=p.offset()),d.top-=h.top,d.left-=h.left,t||n||(i="below"),u||!c||t?!c&&u&&t&&(i="below"):i="above",("above"==i||t&&"below"!==i)&&(d.top=o.top-h.top-s),null!=i&&(this.$dropdown.removeClass("select2-dropdown--below select2-dropdown--above").addClass("select2-dropdown--"+i),this.$container.removeClass("select2-container--below select2-container--above").addClass("select2-container--"+i)),this.$dropdownContainer.css(d)},e.prototype._resizeDropdown=function(){var e={width:this.$container.outerWidth(!1)+"px"};this.options.get("dropdownAutoWidth")&&(e.minWidth=e.width,e.position="relative",e.width="auto"),this.$dropdown.css(e)},e.prototype._showDropdown=function(e){this.$dropdownContainer.appendTo(this.$dropdownParent),this._positionDropdown(),this._resizeDropdown()},e}),e.define("select2/dropdown/minimumResultsForSearch",[],function(){function e(e,t,n,i){this.minimumResultsForSearch=n.get("minimumResultsForSearch"),this.minimumResultsForSearch<0&&(this.minimumResultsForSearch=1/0),e.call(this,t,n,i)}return e.prototype.showSearch=function(e,t){return!(function e(t){for(var n=0,i=0;i');return e.attr("dir",this.options.get("dir")),this.$container=e,this.$container.addClass("select2-container--"+this.options.get("theme")),u.StoreData(e[0],"element",this.$element),e},d}),e.define("select2/compat/utils",["jquery"],function(s){return{syncCssClasses:function(e,t,n){var i,r,o=[];(i=s.trim(e.attr("class")))&&s((i=""+i).split(/\s+/)).each(function(){0===this.indexOf("select2-")&&o.push(this)}),(i=s.trim(t.attr("class")))&&s((i=""+i).split(/\s+/)).each(function(){0!==this.indexOf("select2-")&&null!=(r=n(this))&&o.push(r)}),e.attr("class",o.join(" "))}}}),e.define("select2/compat/containerCss",["jquery","./utils"],function(s,a){function l(e){return null}function e(){}return e.prototype.render=function(e){var t=e.call(this),n=this.options.get("containerCssClass")||"";s.isFunction(n)&&(n=n(this.$element));var i=this.options.get("adaptContainerCssClass");if(i=i||l,-1!==n.indexOf(":all:")){n=n.replace(":all:","");var r=i;i=function(e){var t=r(e);return null!=t?t+" "+e:e}}var o=this.options.get("containerCss")||{};return s.isFunction(o)&&(o=o(this.$element)),a.syncCssClasses(t,this.$element,i),t.css(o),t.addClass(n),t},e}),e.define("select2/compat/dropdownCss",["jquery","./utils"],function(s,a){function l(e){return null}function e(){}return e.prototype.render=function(e){var t=e.call(this),n=this.options.get("dropdownCssClass")||"";s.isFunction(n)&&(n=n(this.$element));var i=this.options.get("adaptDropdownCssClass");if(i=i||l,-1!==n.indexOf(":all:")){n=n.replace(":all:","");var r=i;i=function(e){var t=r(e);return null!=t?t+" "+e:e}}var o=this.options.get("dropdownCss")||{};return s.isFunction(o)&&(o=o(this.$element)),a.syncCssClasses(t,this.$element,i),t.css(o),t.addClass(n),t},e}),e.define("select2/compat/initSelection",["jquery"],function(i){function e(e,t,n){n.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=n.get("initSelection"),this._isInitialized=!1,e.call(this,t,n)}return e.prototype.current=function(e,t){var n=this;this._isInitialized?e.call(this,t):this.initSelection.call(null,this.$element,function(e){n._isInitialized=!0,i.isArray(e)||(e=[e]),t(e)})},e}),e.define("select2/compat/inputData",["jquery","../utils"],function(s,i){function e(e,t,n){this._currentData=[],this._valueSeparator=n.get("valueSeparator")||",","hidden"===t.prop("type")&&n.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=t,this.$search=t.find("input");var n=e.call(this);return this._transferTabIndex(),n},e.prototype.bind=function(e,t,n){var r=this,i=t.id+"-results";e.call(this,t,n),t.on("open",function(){r.$search.attr("aria-controls",i),r.$search.trigger("focus")}),t.on("close",function(){r.$search.val(""),r.$search.removeAttr("aria-controls"),r.$search.removeAttr("aria-activedescendant"),r.$search.trigger("focus")}),t.on("enable",function(){r.$search.prop("disabled",!1),r._transferTabIndex()}),t.on("disable",function(){r.$search.prop("disabled",!0)}),t.on("focus",function(e){r.$search.trigger("focus")}),t.on("results:focus",function(e){e.data._resultId?r.$search.attr("aria-activedescendant",e.data._resultId):r.$search.removeAttr("aria-activedescendant")}),this.$selection.on("focusin",".select2-search--inline",function(e){r.trigger("focus",e)}),this.$selection.on("focusout",".select2-search--inline",function(e){r._handleBlur(e)}),this.$selection.on("keydown",".select2-search--inline",function(e){if(e.stopPropagation(),r.trigger("keypress",e),r._keyUpPrevented=e.isDefaultPrevented(),e.which===l.BACKSPACE&&""===r.$search.val()){var t=r.$searchContainer.prev(".select2-selection__choice");if(0this.maximumInputLength?this.trigger("results:message",{message:"inputTooLong",args:{maximum:this.maximumInputLength,input:t.term,params:t}}):e.call(this,t,n)},e}),e.define("select2/data/maximumSelectionLength",[],function(){function e(e,t,n){this.maximumSelectionLength=n.get("maximumSelectionLength"),e.call(this,t,n)}return e.prototype.bind=function(e,t,n){var r=this;e.call(this,t,n),t.on("select",function(){r._checkIfMaximumSelected()})},e.prototype.query=function(e,t,n){var r=this;this._checkIfMaximumSelected(function(){e.call(r,t,n)})},e.prototype._checkIfMaximumSelected=function(e,n){var r=this;this.current(function(e){var t=null!=e?e.length:0;0=r.maximumSelectionLength?r.trigger("results:message",{message:"maximumSelected",args:{maximum:r.maximumSelectionLength}}):n&&n()})},e}),e.define("select2/dropdown",["jquery","./utils"],function(t,e){function n(e,t){this.$element=e,this.options=t,n.__super__.constructor.call(this)}return e.Extend(n,e.Observable),n.prototype.render=function(){var e=t('');return e.attr("dir",this.options.get("dir")),this.$dropdown=e},n.prototype.bind=function(){},n.prototype.position=function(e,t){},n.prototype.destroy=function(){this.$dropdown.remove()},n}),e.define("select2/dropdown/search",["jquery","../utils"],function(o,e){function t(){}return t.prototype.render=function(e){var t=e.call(this),n=o('');return this.$searchContainer=n,this.$search=n.find("input"),t.prepend(n),t},t.prototype.bind=function(e,t,n){var r=this,i=t.id+"-results";e.call(this,t,n),this.$search.on("keydown",function(e){r.trigger("keypress",e),r._keyUpPrevented=e.isDefaultPrevented()}),this.$search.on("input",function(e){o(this).off("keyup")}),this.$search.on("keyup input",function(e){r.handleSearch(e)}),t.on("open",function(){r.$search.attr("tabindex",0),r.$search.attr("aria-controls",i),r.$search.trigger("focus"),window.setTimeout(function(){r.$search.trigger("focus")},0)}),t.on("close",function(){r.$search.attr("tabindex",-1),r.$search.removeAttr("aria-controls"),r.$search.removeAttr("aria-activedescendant"),r.$search.val(""),r.$search.trigger("blur")}),t.on("focus",function(){t.isOpen()||r.$search.trigger("focus")}),t.on("results:all",function(e){null!=e.query.term&&""!==e.query.term||(r.showSearch(e)?r.$searchContainer.removeClass("select2-search--hide"):r.$searchContainer.addClass("select2-search--hide"))}),t.on("results:focus",function(e){e.data._resultId?r.$search.attr("aria-activedescendant",e.data._resultId):r.$search.removeAttr("aria-activedescendant")})},t.prototype.handleSearch=function(e){if(!this._keyUpPrevented){var t=this.$search.val();this.trigger("query",{term:t})}this._keyUpPrevented=!1},t.prototype.showSearch=function(e,t){return!0},t}),e.define("select2/dropdown/hidePlaceholder",[],function(){function e(e,t,n,r){this.placeholder=this.normalizePlaceholder(n.get("placeholder")),e.call(this,t,n,r)}return e.prototype.append=function(e,t){t.results=this.removePlaceholder(t.results),e.call(this,t)},e.prototype.normalizePlaceholder=function(e,t){return"string"==typeof t&&(t={id:"",text:t}),t},e.prototype.removePlaceholder=function(e,t){for(var n=t.slice(0),r=t.length-1;0<=r;r--){var i=t[r];this.placeholder.id===i.id&&n.splice(r,1)}return n},e}),e.define("select2/dropdown/infiniteScroll",["jquery"],function(n){function e(e,t,n,r){this.lastParams={},e.call(this,t,n,r),this.$loadingMore=this.createLoadingMore(),this.loading=!1}return e.prototype.append=function(e,t){this.$loadingMore.remove(),this.loading=!1,e.call(this,t),this.showLoadingMore(t)&&(this.$results.append(this.$loadingMore),this.loadMoreIfNeeded())},e.prototype.bind=function(e,t,n){var r=this;e.call(this,t,n),t.on("query",function(e){r.lastParams=e,r.loading=!0}),t.on("query:append",function(e){r.lastParams=e,r.loading=!0}),this.$results.on("scroll",this.loadMoreIfNeeded.bind(this))},e.prototype.loadMoreIfNeeded=function(){var e=n.contains(document.documentElement,this.$loadingMore[0]);if(!this.loading&&e){var t=this.$results.offset().top+this.$results.outerHeight(!1);this.$loadingMore.offset().top+this.$loadingMore.outerHeight(!1)<=t+50&&this.loadMore()}},e.prototype.loadMore=function(){this.loading=!0;var e=n.extend({},{page:1},this.lastParams);e.page++,this.trigger("query:append",e)},e.prototype.showLoadingMore=function(e,t){return t.pagination&&t.pagination.more},e.prototype.createLoadingMore=function(){var e=n('
      • '),t=this.options.get("translations").get("loadingMore");return e.html(t(this.lastParams)),e},e}),e.define("select2/dropdown/attachBody",["jquery","../utils"],function(f,a){function e(e,t,n){this.$dropdownParent=f(n.get("dropdownParent")||document.body),e.call(this,t,n)}return e.prototype.bind=function(e,t,n){var r=this;e.call(this,t,n),t.on("open",function(){r._showDropdown(),r._attachPositioningHandler(t),r._bindContainerResultHandlers(t)}),t.on("close",function(){r._hideDropdown(),r._detachPositioningHandler(t)}),this.$dropdownContainer.on("mousedown",function(e){e.stopPropagation()})},e.prototype.destroy=function(e){e.call(this),this.$dropdownContainer.remove()},e.prototype.position=function(e,t,n){t.attr("class",n.attr("class")),t.removeClass("select2"),t.addClass("select2-container--open"),t.css({position:"absolute",top:-999999}),this.$container=n},e.prototype.render=function(e){var t=f(""),n=e.call(this);return t.append(n),this.$dropdownContainer=t},e.prototype._hideDropdown=function(e){this.$dropdownContainer.detach()},e.prototype._bindContainerResultHandlers=function(e,t){if(!this._containerResultsHandlersBound){var n=this;t.on("results:all",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("results:append",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("results:message",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("select",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("unselect",function(){n._positionDropdown(),n._resizeDropdown()}),this._containerResultsHandlersBound=!0}},e.prototype._attachPositioningHandler=function(e,t){var n=this,r="scroll.select2."+t.id,i="resize.select2."+t.id,o="orientationchange.select2."+t.id,s=this.$container.parents().filter(a.hasScroll);s.each(function(){a.StoreData(this,"select2-scroll-position",{x:f(this).scrollLeft(),y:f(this).scrollTop()})}),s.on(r,function(e){var t=a.GetData(this,"select2-scroll-position");f(this).scrollTop(t.y)}),f(window).on(r+" "+i+" "+o,function(e){n._positionDropdown(),n._resizeDropdown()})},e.prototype._detachPositioningHandler=function(e,t){var n="scroll.select2."+t.id,r="resize.select2."+t.id,i="orientationchange.select2."+t.id;this.$container.parents().filter(a.hasScroll).off(n),f(window).off(n+" "+r+" "+i)},e.prototype._positionDropdown=function(){var e=f(window),t=this.$dropdown.hasClass("select2-dropdown--above"),n=this.$dropdown.hasClass("select2-dropdown--below"),r=null,i=this.$container.offset();i.bottom=i.top+this.$container.outerHeight(!1);var o={height:this.$container.outerHeight(!1)};o.top=i.top,o.bottom=i.top+o.height;var s=this.$dropdown.outerHeight(!1),a=e.scrollTop(),l=e.scrollTop()+e.height(),c=ai.bottom+s,d={left:i.left,top:o.bottom},p=this.$dropdownParent;"static"===p.css("position")&&(p=p.offsetParent());var h={top:0,left:0};(f.contains(document.body,p[0])||p[0].isConnected)&&(h=p.offset()),d.top-=h.top,d.left-=h.left,t||n||(r="below"),u||!c||t?!c&&u&&t&&(r="below"):r="above",("above"==r||t&&"below"!==r)&&(d.top=o.top-h.top-s),null!=r&&(this.$dropdown.removeClass("select2-dropdown--below select2-dropdown--above").addClass("select2-dropdown--"+r),this.$container.removeClass("select2-container--below select2-container--above").addClass("select2-container--"+r)),this.$dropdownContainer.css(d)},e.prototype._resizeDropdown=function(){var e={width:this.$container.outerWidth(!1)+"px"};this.options.get("dropdownAutoWidth")&&(e.minWidth=e.width,e.position="relative",e.width="auto"),this.$dropdown.css(e)},e.prototype._showDropdown=function(e){this.$dropdownContainer.appendTo(this.$dropdownParent),this._positionDropdown(),this._resizeDropdown()},e}),e.define("select2/dropdown/minimumResultsForSearch",[],function(){function e(e,t,n,r){this.minimumResultsForSearch=n.get("minimumResultsForSearch"),this.minimumResultsForSearch<0&&(this.minimumResultsForSearch=1/0),e.call(this,t,n,r)}return e.prototype.showSearch=function(e,t){return!(function e(t){for(var n=0,r=0;r');return e.attr("dir",this.options.get("dir")),this.$container=e,this.$container.addClass("select2-container--"+this.options.get("theme")),u.StoreData(e[0],"element",this.$element),e},d}),e.define("jquery-mousewheel",["jquery"],function(e){return e}),e.define("jquery.select2",["jquery","jquery-mousewheel","./select2/core","./select2/defaults","./select2/utils"],function(i,e,o,t,s){if(null==i.fn.select2){var a=["open","close","destroy"];i.fn.select2=function(t){if("object"==typeof(t=t||{}))return this.each(function(){var e=i.extend(!0,{},t);new o(i(this),e)}),this;if("string"!=typeof t)throw new Error("Invalid arguments for Select2: "+t);var n,r=Array.prototype.slice.call(arguments,1);return this.each(function(){var e=s.GetData(this,"select2");null==e&&window.console&&console.error&&console.error("The select2('"+t+"') method was called on an element that is not using Select2."),n=e[t].apply(e,r)}),-1` section of your page: ``` - - + + ``` >>> Immediately following a new release, it takes some time for CDNs to catch up and get the new versions live on the CDN. diff --git a/netbox/project-static/select2-4.0.12/docs/pages/01.getting-started/02.basic-usage/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/01.getting-started/02.basic-usage/docs.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/01.getting-started/02.basic-usage/docs.md rename to netbox/project-static/select2-4.0.13/docs/pages/01.getting-started/02.basic-usage/docs.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/01.getting-started/03.builds-and-modules/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/01.getting-started/03.builds-and-modules/docs.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/01.getting-started/03.builds-and-modules/docs.md rename to netbox/project-static/select2-4.0.13/docs/pages/01.getting-started/03.builds-and-modules/docs.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/01.getting-started/chapter.md b/netbox/project-static/select2-4.0.13/docs/pages/01.getting-started/chapter.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/01.getting-started/chapter.md rename to netbox/project-static/select2-4.0.13/docs/pages/01.getting-started/chapter.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/02.troubleshooting/01.getting-help/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/02.troubleshooting/01.getting-help/docs.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/02.troubleshooting/01.getting-help/docs.md rename to netbox/project-static/select2-4.0.13/docs/pages/02.troubleshooting/01.getting-help/docs.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/02.troubleshooting/02.common-problems/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/02.troubleshooting/02.common-problems/docs.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/02.troubleshooting/02.common-problems/docs.md rename to netbox/project-static/select2-4.0.13/docs/pages/02.troubleshooting/02.common-problems/docs.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/02.troubleshooting/chapter.md b/netbox/project-static/select2-4.0.13/docs/pages/02.troubleshooting/chapter.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/02.troubleshooting/chapter.md rename to netbox/project-static/select2-4.0.13/docs/pages/02.troubleshooting/chapter.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/03.configuration/01.options-api/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/03.configuration/01.options-api/docs.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/03.configuration/01.options-api/docs.md rename to netbox/project-static/select2-4.0.13/docs/pages/03.configuration/01.options-api/docs.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/03.configuration/02.defaults/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/03.configuration/02.defaults/docs.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/03.configuration/02.defaults/docs.md rename to netbox/project-static/select2-4.0.13/docs/pages/03.configuration/02.defaults/docs.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/03.configuration/03.data-attributes/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/03.configuration/03.data-attributes/docs.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/03.configuration/03.data-attributes/docs.md rename to netbox/project-static/select2-4.0.13/docs/pages/03.configuration/03.data-attributes/docs.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/03.configuration/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/03.configuration/docs.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/03.configuration/docs.md rename to netbox/project-static/select2-4.0.13/docs/pages/03.configuration/docs.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/04.appearance/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/04.appearance/docs.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/04.appearance/docs.md rename to netbox/project-static/select2-4.0.13/docs/pages/04.appearance/docs.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/05.options/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/05.options/docs.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/05.options/docs.md rename to netbox/project-static/select2-4.0.13/docs/pages/05.options/docs.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/06.data-sources/01.formats/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/06.data-sources/01.formats/docs.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/06.data-sources/01.formats/docs.md rename to netbox/project-static/select2-4.0.13/docs/pages/06.data-sources/01.formats/docs.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/06.data-sources/02.ajax/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/06.data-sources/02.ajax/docs.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/06.data-sources/02.ajax/docs.md rename to netbox/project-static/select2-4.0.13/docs/pages/06.data-sources/02.ajax/docs.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/06.data-sources/03.arrays/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/06.data-sources/03.arrays/docs.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/06.data-sources/03.arrays/docs.md rename to netbox/project-static/select2-4.0.13/docs/pages/06.data-sources/03.arrays/docs.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/06.data-sources/chapter.md b/netbox/project-static/select2-4.0.13/docs/pages/06.data-sources/chapter.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/06.data-sources/chapter.md rename to netbox/project-static/select2-4.0.13/docs/pages/06.data-sources/chapter.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/07.dropdown/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/07.dropdown/docs.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/07.dropdown/docs.md rename to netbox/project-static/select2-4.0.13/docs/pages/07.dropdown/docs.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/08.selections/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/08.selections/docs.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/08.selections/docs.md rename to netbox/project-static/select2-4.0.13/docs/pages/08.selections/docs.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/09.tagging/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/09.tagging/docs.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/09.tagging/docs.md rename to netbox/project-static/select2-4.0.13/docs/pages/09.tagging/docs.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/10.placeholders/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/10.placeholders/docs.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/10.placeholders/docs.md rename to netbox/project-static/select2-4.0.13/docs/pages/10.placeholders/docs.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/11.searching/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/11.searching/docs.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/11.searching/docs.md rename to netbox/project-static/select2-4.0.13/docs/pages/11.searching/docs.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/12.programmatic-control/01.add-select-clear-items/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/12.programmatic-control/01.add-select-clear-items/docs.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/12.programmatic-control/01.add-select-clear-items/docs.md rename to netbox/project-static/select2-4.0.13/docs/pages/12.programmatic-control/01.add-select-clear-items/docs.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/12.programmatic-control/02.retrieving-selections/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/12.programmatic-control/02.retrieving-selections/docs.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/12.programmatic-control/02.retrieving-selections/docs.md rename to netbox/project-static/select2-4.0.13/docs/pages/12.programmatic-control/02.retrieving-selections/docs.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/12.programmatic-control/03.methods/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/12.programmatic-control/03.methods/docs.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/12.programmatic-control/03.methods/docs.md rename to netbox/project-static/select2-4.0.13/docs/pages/12.programmatic-control/03.methods/docs.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/12.programmatic-control/04.events/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/12.programmatic-control/04.events/docs.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/12.programmatic-control/04.events/docs.md rename to netbox/project-static/select2-4.0.13/docs/pages/12.programmatic-control/04.events/docs.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/12.programmatic-control/chapter.md b/netbox/project-static/select2-4.0.13/docs/pages/12.programmatic-control/chapter.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/12.programmatic-control/chapter.md rename to netbox/project-static/select2-4.0.13/docs/pages/12.programmatic-control/chapter.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/13.i18n/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/13.i18n/docs.md similarity index 98% rename from netbox/project-static/select2-4.0.12/docs/pages/13.i18n/docs.md rename to netbox/project-static/select2-4.0.13/docs/pages/13.i18n/docs.md index da9cef4fa..227cc9f34 100644 --- a/netbox/project-static/select2-4.0.12/docs/pages/13.i18n/docs.md +++ b/netbox/project-static/select2-4.0.13/docs/pages/13.i18n/docs.md @@ -7,7 +7,7 @@ process: never_cache_twig: true --- -{% do assets.addJs('https://cdn.jsdelivr.net/npm/select2@4.0.12/dist/js/i18n/es.js', 90) %} +{% do assets.addJs('https://cdn.jsdelivr.net/npm/select2@4.0.13/dist/js/i18n/es.js', 90) %} ## Message translations diff --git a/netbox/project-static/select2-4.0.12/docs/pages/14.advanced/01.adapters-and-decorators/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/14.advanced/01.adapters-and-decorators/docs.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/14.advanced/01.adapters-and-decorators/docs.md rename to netbox/project-static/select2-4.0.13/docs/pages/14.advanced/01.adapters-and-decorators/docs.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/14.advanced/02.default-adapters/01.selection/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/14.advanced/02.default-adapters/01.selection/docs.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/14.advanced/02.default-adapters/01.selection/docs.md rename to netbox/project-static/select2-4.0.13/docs/pages/14.advanced/02.default-adapters/01.selection/docs.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/14.advanced/02.default-adapters/02.array/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/14.advanced/02.default-adapters/02.array/docs.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/14.advanced/02.default-adapters/02.array/docs.md rename to netbox/project-static/select2-4.0.13/docs/pages/14.advanced/02.default-adapters/02.array/docs.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/14.advanced/02.default-adapters/03.ajax/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/14.advanced/02.default-adapters/03.ajax/docs.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/14.advanced/02.default-adapters/03.ajax/docs.md rename to netbox/project-static/select2-4.0.13/docs/pages/14.advanced/02.default-adapters/03.ajax/docs.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/14.advanced/02.default-adapters/04.data/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/14.advanced/02.default-adapters/04.data/docs.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/14.advanced/02.default-adapters/04.data/docs.md rename to netbox/project-static/select2-4.0.13/docs/pages/14.advanced/02.default-adapters/04.data/docs.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/14.advanced/02.default-adapters/05.results/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/14.advanced/02.default-adapters/05.results/docs.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/14.advanced/02.default-adapters/05.results/docs.md rename to netbox/project-static/select2-4.0.13/docs/pages/14.advanced/02.default-adapters/05.results/docs.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/14.advanced/02.default-adapters/06.dropdown/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/14.advanced/02.default-adapters/06.dropdown/docs.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/14.advanced/02.default-adapters/06.dropdown/docs.md rename to netbox/project-static/select2-4.0.13/docs/pages/14.advanced/02.default-adapters/06.dropdown/docs.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/14.advanced/02.default-adapters/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/14.advanced/02.default-adapters/docs.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/14.advanced/02.default-adapters/docs.md rename to netbox/project-static/select2-4.0.13/docs/pages/14.advanced/02.default-adapters/docs.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/14.advanced/chapter.md b/netbox/project-static/select2-4.0.13/docs/pages/14.advanced/chapter.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/14.advanced/chapter.md rename to netbox/project-static/select2-4.0.13/docs/pages/14.advanced/chapter.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/15.upgrading/01.new-in-40/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/15.upgrading/01.new-in-40/docs.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/15.upgrading/01.new-in-40/docs.md rename to netbox/project-static/select2-4.0.13/docs/pages/15.upgrading/01.new-in-40/docs.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/15.upgrading/02.migrating-from-35/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/15.upgrading/02.migrating-from-35/docs.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/15.upgrading/02.migrating-from-35/docs.md rename to netbox/project-static/select2-4.0.13/docs/pages/15.upgrading/02.migrating-from-35/docs.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/15.upgrading/chapter.md b/netbox/project-static/select2-4.0.13/docs/pages/15.upgrading/chapter.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/15.upgrading/chapter.md rename to netbox/project-static/select2-4.0.13/docs/pages/15.upgrading/chapter.md diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/ak.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/ak.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/ak.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/ak.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/al.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/al.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/al.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/al.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/ar.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/ar.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/ar.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/ar.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/az.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/az.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/az.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/az.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/ca.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/ca.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/ca.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/ca.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/co.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/co.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/co.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/co.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/ct.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/ct.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/ct.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/ct.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/de.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/de.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/de.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/de.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/fl.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/fl.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/fl.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/fl.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/ga.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/ga.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/ga.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/ga.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/hi.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/hi.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/hi.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/hi.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/ia.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/ia.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/ia.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/ia.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/id.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/id.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/id.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/id.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/il.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/il.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/il.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/il.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/in.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/in.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/in.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/in.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/ks.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/ks.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/ks.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/ks.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/ky.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/ky.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/ky.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/ky.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/la.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/la.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/la.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/la.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/ma.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/ma.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/ma.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/ma.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/md.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/md.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/md.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/md.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/me.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/me.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/me.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/me.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/mi.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/mi.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/mi.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/mi.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/mn.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/mn.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/mn.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/mn.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/mo.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/mo.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/mo.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/mo.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/ms.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/ms.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/ms.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/ms.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/mt.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/mt.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/mt.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/mt.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/nc.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/nc.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/nc.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/nc.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/nd.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/nd.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/nd.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/nd.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/ne.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/ne.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/ne.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/ne.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/nh.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/nh.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/nh.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/nh.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/nj.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/nj.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/nj.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/nj.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/nm.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/nm.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/nm.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/nm.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/nv.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/nv.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/nv.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/nv.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/ny.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/ny.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/ny.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/ny.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/oh.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/oh.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/oh.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/oh.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/ok.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/ok.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/ok.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/ok.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/or.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/or.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/or.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/or.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/pa.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/pa.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/pa.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/pa.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/ri.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/ri.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/ri.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/ri.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/sc.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/sc.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/sc.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/sc.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/sd.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/sd.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/sd.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/sd.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/tn.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/tn.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/tn.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/tn.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/tx.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/tx.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/tx.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/tx.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/ut.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/ut.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/ut.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/ut.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/va.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/va.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/va.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/va.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/vt.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/vt.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/vt.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/vt.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/wa.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/wa.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/wa.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/wa.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/wi.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/wi.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/wi.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/wi.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/wv.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/wv.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/wv.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/wv.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/wy.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/wy.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/wy.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/wy.png diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/logo.png b/netbox/project-static/select2-4.0.13/docs/pages/images/logo.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/pages/images/logo.png rename to netbox/project-static/select2-4.0.13/docs/pages/images/logo.png diff --git a/netbox/project-static/select2-4.0.12/docs/themes/.gitkeep b/netbox/project-static/select2-4.0.13/docs/plugins/.gitkeep similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/.gitkeep rename to netbox/project-static/select2-4.0.13/docs/plugins/.gitkeep diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/anchors/CHANGELOG.md b/netbox/project-static/select2-4.0.13/docs/plugins/anchors/CHANGELOG.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/anchors/CHANGELOG.md rename to netbox/project-static/select2-4.0.13/docs/plugins/anchors/CHANGELOG.md diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/anchors/LICENSE b/netbox/project-static/select2-4.0.13/docs/plugins/anchors/LICENSE similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/anchors/LICENSE rename to netbox/project-static/select2-4.0.13/docs/plugins/anchors/LICENSE diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/anchors/README.md b/netbox/project-static/select2-4.0.13/docs/plugins/anchors/README.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/anchors/README.md rename to netbox/project-static/select2-4.0.13/docs/plugins/anchors/README.md diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/anchors/anchors.php b/netbox/project-static/select2-4.0.13/docs/plugins/anchors/anchors.php similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/anchors/anchors.php rename to netbox/project-static/select2-4.0.13/docs/plugins/anchors/anchors.php diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/anchors/anchors.yaml b/netbox/project-static/select2-4.0.13/docs/plugins/anchors/anchors.yaml similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/anchors/anchors.yaml rename to netbox/project-static/select2-4.0.13/docs/plugins/anchors/anchors.yaml diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/anchors/blueprints.yaml b/netbox/project-static/select2-4.0.13/docs/plugins/anchors/blueprints.yaml similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/anchors/blueprints.yaml rename to netbox/project-static/select2-4.0.13/docs/plugins/anchors/blueprints.yaml diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/anchors/js/anchor.min.js b/netbox/project-static/select2-4.0.13/docs/plugins/anchors/js/anchor.min.js similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/anchors/js/anchor.min.js rename to netbox/project-static/select2-4.0.13/docs/plugins/anchors/js/anchor.min.js diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/CHANGELOG.md b/netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/CHANGELOG.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/CHANGELOG.md rename to netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/CHANGELOG.md diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/LICENSE b/netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/LICENSE similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/LICENSE rename to netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/LICENSE diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/README.md b/netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/README.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/README.md rename to netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/README.md diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/assets/readme_1.png b/netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/assets/readme_1.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/assets/readme_1.png rename to netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/assets/readme_1.png diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/blueprints.yaml b/netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/blueprints.yaml similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/blueprints.yaml rename to netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/blueprints.yaml diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/breadcrumbs.php b/netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/breadcrumbs.php similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/breadcrumbs.php rename to netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/breadcrumbs.php diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/breadcrumbs.yaml b/netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/breadcrumbs.yaml similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/breadcrumbs.yaml rename to netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/breadcrumbs.yaml diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/classes/breadcrumbs.php b/netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/classes/breadcrumbs.php similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/classes/breadcrumbs.php rename to netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/classes/breadcrumbs.php diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/css/breadcrumbs.css b/netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/css/breadcrumbs.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/css/breadcrumbs.css rename to netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/css/breadcrumbs.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/templates/partials/breadcrumbs.html.twig b/netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/templates/partials/breadcrumbs.html.twig similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/templates/partials/breadcrumbs.html.twig rename to netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/templates/partials/breadcrumbs.html.twig diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/error/CHANGELOG.md b/netbox/project-static/select2-4.0.13/docs/plugins/error/CHANGELOG.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/error/CHANGELOG.md rename to netbox/project-static/select2-4.0.13/docs/plugins/error/CHANGELOG.md diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/error/LICENSE b/netbox/project-static/select2-4.0.13/docs/plugins/error/LICENSE similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/error/LICENSE rename to netbox/project-static/select2-4.0.13/docs/plugins/error/LICENSE diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/error/README.md b/netbox/project-static/select2-4.0.13/docs/plugins/error/README.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/error/README.md rename to netbox/project-static/select2-4.0.13/docs/plugins/error/README.md diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/error/assets/readme_1.png b/netbox/project-static/select2-4.0.13/docs/plugins/error/assets/readme_1.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/error/assets/readme_1.png rename to netbox/project-static/select2-4.0.13/docs/plugins/error/assets/readme_1.png diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/error/blueprints.yaml b/netbox/project-static/select2-4.0.13/docs/plugins/error/blueprints.yaml similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/error/blueprints.yaml rename to netbox/project-static/select2-4.0.13/docs/plugins/error/blueprints.yaml diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/error/cli/LogCommand.php b/netbox/project-static/select2-4.0.13/docs/plugins/error/cli/LogCommand.php similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/error/cli/LogCommand.php rename to netbox/project-static/select2-4.0.13/docs/plugins/error/cli/LogCommand.php diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/error/error.php b/netbox/project-static/select2-4.0.13/docs/plugins/error/error.php similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/error/error.php rename to netbox/project-static/select2-4.0.13/docs/plugins/error/error.php diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/error/error.yaml b/netbox/project-static/select2-4.0.13/docs/plugins/error/error.yaml similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/error/error.yaml rename to netbox/project-static/select2-4.0.13/docs/plugins/error/error.yaml diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/error/languages.yaml b/netbox/project-static/select2-4.0.13/docs/plugins/error/languages.yaml similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/error/languages.yaml rename to netbox/project-static/select2-4.0.13/docs/plugins/error/languages.yaml diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/error/pages/error.md b/netbox/project-static/select2-4.0.13/docs/plugins/error/pages/error.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/error/pages/error.md rename to netbox/project-static/select2-4.0.13/docs/plugins/error/pages/error.md diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/error/templates/error.html.twig b/netbox/project-static/select2-4.0.13/docs/plugins/error/templates/error.html.twig similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/error/templates/error.html.twig rename to netbox/project-static/select2-4.0.13/docs/plugins/error/templates/error.html.twig diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/error/templates/error.json.twig b/netbox/project-static/select2-4.0.13/docs/plugins/error/templates/error.json.twig similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/error/templates/error.json.twig rename to netbox/project-static/select2-4.0.13/docs/plugins/error/templates/error.json.twig diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/CHANGELOG.md b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/CHANGELOG.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/CHANGELOG.md rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/CHANGELOG.md diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/LICENSE b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/LICENSE similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/LICENSE rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/LICENSE diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/README.md b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/README.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/README.md rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/README.md diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/assets/readme_1.png b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/assets/readme_1.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/assets/readme_1.png rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/assets/readme_1.png diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/blueprints.yaml b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/blueprints.yaml similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/blueprints.yaml rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/blueprints.yaml diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/agate.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/agate.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/agate.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/agate.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/androidstudio.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/androidstudio.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/androidstudio.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/androidstudio.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/arduino-light.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/arduino-light.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/arduino-light.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/arduino-light.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/arta.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/arta.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/arta.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/arta.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/ascetic.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/ascetic.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/ascetic.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/ascetic.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-cave.dark.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-cave.dark.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-cave.dark.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-cave.dark.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-cave.light.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-cave.light.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-cave.light.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-cave.light.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-dune.dark.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-dune.dark.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-dune.dark.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-dune.dark.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-dune.light.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-dune.light.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-dune.light.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-dune.light.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-estuary.dark.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-estuary.dark.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-estuary.dark.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-estuary.dark.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-estuary.light.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-estuary.light.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-estuary.light.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-estuary.light.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-forest.dark.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-forest.dark.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-forest.dark.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-forest.dark.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-forest.light.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-forest.light.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-forest.light.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-forest.light.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-heath.dark.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-heath.dark.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-heath.dark.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-heath.dark.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-heath.light.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-heath.light.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-heath.light.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-heath.light.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-lakeside.dark.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-lakeside.dark.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-lakeside.dark.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-lakeside.dark.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-lakeside.light.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-lakeside.light.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-lakeside.light.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-lakeside.light.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-plateau.dark.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-plateau.dark.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-plateau.dark.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-plateau.dark.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-plateau.light.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-plateau.light.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-plateau.light.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-plateau.light.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-savanna.dark.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-savanna.dark.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-savanna.dark.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-savanna.dark.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-savanna.light.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-savanna.light.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-savanna.light.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-savanna.light.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-seaside.dark.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-seaside.dark.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-seaside.dark.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-seaside.dark.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-seaside.light.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-seaside.light.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-seaside.light.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-seaside.light.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-sulphurpool.dark.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-sulphurpool.dark.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-sulphurpool.dark.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-sulphurpool.dark.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-sulphurpool.light.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-sulphurpool.light.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-sulphurpool.light.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-sulphurpool.light.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/brown-paper.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/brown-paper.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/brown-paper.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/brown-paper.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/codepen-embed.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/codepen-embed.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/codepen-embed.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/codepen-embed.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/color-brewer.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/color-brewer.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/color-brewer.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/color-brewer.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/dark.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/dark.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/dark.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/dark.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/darkula.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/darkula.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/darkula.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/darkula.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/default.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/default.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/default.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/default.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/docco.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/docco.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/docco.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/docco.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/far.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/far.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/far.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/far.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/foundation.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/foundation.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/foundation.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/foundation.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/github-gist.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/github-gist.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/github-gist.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/github-gist.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/github.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/github.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/github.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/github.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/googlecode.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/googlecode.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/googlecode.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/googlecode.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/grayscale.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/grayscale.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/grayscale.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/grayscale.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/hopscotch.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/hopscotch.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/hopscotch.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/hopscotch.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/hybrid.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/hybrid.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/hybrid.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/hybrid.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/idea.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/idea.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/idea.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/idea.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/ir-black.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/ir-black.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/ir-black.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/ir-black.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/kimbie.dark.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/kimbie.dark.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/kimbie.dark.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/kimbie.dark.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/kimbie.light.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/kimbie.light.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/kimbie.light.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/kimbie.light.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/learn.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/learn.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/learn.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/learn.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/magula.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/magula.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/magula.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/magula.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/mono-blue.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/mono-blue.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/mono-blue.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/mono-blue.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/monokai-sublime.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/monokai-sublime.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/monokai-sublime.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/monokai-sublime.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/monokai.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/monokai.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/monokai.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/monokai.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/obsidian.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/obsidian.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/obsidian.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/obsidian.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/paraiso-dark.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/paraiso-dark.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/paraiso-dark.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/paraiso-dark.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/paraiso-light.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/paraiso-light.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/paraiso-light.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/paraiso-light.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/paraiso.dark.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/paraiso.dark.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/paraiso.dark.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/paraiso.dark.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/paraiso.light.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/paraiso.light.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/paraiso.light.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/paraiso.light.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/pojoaque.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/pojoaque.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/pojoaque.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/pojoaque.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/railscasts.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/railscasts.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/railscasts.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/railscasts.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/rainbow.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/rainbow.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/rainbow.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/rainbow.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/school-book.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/school-book.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/school-book.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/school-book.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/solarized-dark.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/solarized-dark.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/solarized-dark.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/solarized-dark.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/solarized-light.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/solarized-light.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/solarized-light.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/solarized-light.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/sunburst.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/sunburst.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/sunburst.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/sunburst.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/tomorrow-night-blue.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/tomorrow-night-blue.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/tomorrow-night-blue.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/tomorrow-night-blue.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/tomorrow-night-bright.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/tomorrow-night-bright.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/tomorrow-night-bright.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/tomorrow-night-bright.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/tomorrow-night-eighties.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/tomorrow-night-eighties.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/tomorrow-night-eighties.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/tomorrow-night-eighties.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/tomorrow-night.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/tomorrow-night.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/tomorrow-night.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/tomorrow-night.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/tomorrow.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/tomorrow.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/tomorrow.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/tomorrow.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/vs.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/vs.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/vs.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/vs.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/xcode.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/xcode.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/xcode.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/xcode.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/zenburn.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/zenburn.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/zenburn.css rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/zenburn.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/highlight.php b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/highlight.php similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/highlight.php rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/highlight.php diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/highlight.yaml b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/highlight.yaml similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/highlight.yaml rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/highlight.yaml diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/js/highlight.pack.js b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/js/highlight.pack.js similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/js/highlight.pack.js rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/js/highlight.pack.js diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/js/highlightjs-line-numbers.min.js b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/js/highlightjs-line-numbers.min.js similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/js/highlightjs-line-numbers.min.js rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/js/highlightjs-line-numbers.min.js diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/problems/CHANGELOG.md b/netbox/project-static/select2-4.0.13/docs/plugins/problems/CHANGELOG.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/problems/CHANGELOG.md rename to netbox/project-static/select2-4.0.13/docs/plugins/problems/CHANGELOG.md diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/problems/LICENSE b/netbox/project-static/select2-4.0.13/docs/plugins/problems/LICENSE similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/problems/LICENSE rename to netbox/project-static/select2-4.0.13/docs/plugins/problems/LICENSE diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/problems/README.md b/netbox/project-static/select2-4.0.13/docs/plugins/problems/README.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/problems/README.md rename to netbox/project-static/select2-4.0.13/docs/plugins/problems/README.md diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/problems/assets/readme_1.png b/netbox/project-static/select2-4.0.13/docs/plugins/problems/assets/readme_1.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/problems/assets/readme_1.png rename to netbox/project-static/select2-4.0.13/docs/plugins/problems/assets/readme_1.png diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/problems/blueprints.yaml b/netbox/project-static/select2-4.0.13/docs/plugins/problems/blueprints.yaml similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/problems/blueprints.yaml rename to netbox/project-static/select2-4.0.13/docs/plugins/problems/blueprints.yaml diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/problems/css/problems.css b/netbox/project-static/select2-4.0.13/docs/plugins/problems/css/problems.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/problems/css/problems.css rename to netbox/project-static/select2-4.0.13/docs/plugins/problems/css/problems.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/problems/css/template.css b/netbox/project-static/select2-4.0.13/docs/plugins/problems/css/template.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/problems/css/template.css rename to netbox/project-static/select2-4.0.13/docs/plugins/problems/css/template.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/problems/html/problems.html b/netbox/project-static/select2-4.0.13/docs/plugins/problems/html/problems.html similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/problems/html/problems.html rename to netbox/project-static/select2-4.0.13/docs/plugins/problems/html/problems.html diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/problems/problems.php b/netbox/project-static/select2-4.0.13/docs/plugins/problems/problems.php similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/problems/problems.php rename to netbox/project-static/select2-4.0.13/docs/plugins/problems/problems.php diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/problems/problems.yaml b/netbox/project-static/select2-4.0.13/docs/plugins/problems/problems.yaml similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/problems/problems.yaml rename to netbox/project-static/select2-4.0.13/docs/plugins/problems/problems.yaml diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/CHANGELOG.md b/netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/CHANGELOG.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/CHANGELOG.md rename to netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/CHANGELOG.md diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/LICENSE b/netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/LICENSE similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/LICENSE rename to netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/LICENSE diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/README.md b/netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/README.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/README.md rename to netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/README.md diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/assets/readme_1.png b/netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/assets/readme_1.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/assets/readme_1.png rename to netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/assets/readme_1.png diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/assets/search.svg b/netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/assets/search.svg similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/assets/search.svg rename to netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/assets/search.svg diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/blueprints.yaml b/netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/blueprints.yaml similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/blueprints.yaml rename to netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/blueprints.yaml diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/css/simplesearch.css b/netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/css/simplesearch.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/css/simplesearch.css rename to netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/css/simplesearch.css diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/js/simplesearch.js b/netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/js/simplesearch.js similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/js/simplesearch.js rename to netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/js/simplesearch.js diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/languages.yaml b/netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/languages.yaml similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/languages.yaml rename to netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/languages.yaml diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/pages/simplesearch.md b/netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/pages/simplesearch.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/pages/simplesearch.md rename to netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/pages/simplesearch.md diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/simplesearch.php b/netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/simplesearch.php similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/simplesearch.php rename to netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/simplesearch.php diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/simplesearch.yaml b/netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/simplesearch.yaml similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/simplesearch.yaml rename to netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/simplesearch.yaml diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/templates/partials/simplesearch_base.html.twig b/netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/templates/partials/simplesearch_base.html.twig similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/templates/partials/simplesearch_base.html.twig rename to netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/templates/partials/simplesearch_base.html.twig diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/templates/partials/simplesearch_item.html.twig b/netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/templates/partials/simplesearch_item.html.twig similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/templates/partials/simplesearch_item.html.twig rename to netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/templates/partials/simplesearch_item.html.twig diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/templates/partials/simplesearch_searchbox.html.twig b/netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/templates/partials/simplesearch_searchbox.html.twig similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/templates/partials/simplesearch_searchbox.html.twig rename to netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/templates/partials/simplesearch_searchbox.html.twig diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/templates/simplesearch_results.html.twig b/netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/templates/simplesearch_results.html.twig similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/templates/simplesearch_results.html.twig rename to netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/templates/simplesearch_results.html.twig diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/templates/simplesearch_results.json.twig b/netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/templates/simplesearch_results.json.twig similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/templates/simplesearch_results.json.twig rename to netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/templates/simplesearch_results.json.twig diff --git a/netbox/project-static/select2-4.0.12/docs/screenshot.jpg b/netbox/project-static/select2-4.0.13/docs/screenshot.jpg similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/screenshot.jpg rename to netbox/project-static/select2-4.0.13/docs/screenshot.jpg diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/particles/_visibility.scss b/netbox/project-static/select2-4.0.13/docs/themes/.gitkeep similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/particles/_visibility.scss rename to netbox/project-static/select2-4.0.13/docs/themes/.gitkeep diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/CHANGELOG.md b/netbox/project-static/select2-4.0.13/docs/themes/learn2/CHANGELOG.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/CHANGELOG.md rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/CHANGELOG.md diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/LICENSE b/netbox/project-static/select2-4.0.13/docs/themes/learn2/LICENSE similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/LICENSE rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/LICENSE diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/README.md b/netbox/project-static/select2-4.0.13/docs/themes/learn2/README.md similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/README.md rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/README.md diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/blueprints.yaml b/netbox/project-static/select2-4.0.13/docs/themes/learn2/blueprints.yaml similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/blueprints.yaml rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/blueprints.yaml diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/blueprints/chapter.yaml b/netbox/project-static/select2-4.0.13/docs/themes/learn2/blueprints/chapter.yaml similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/blueprints/chapter.yaml rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/blueprints/chapter.yaml diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/blueprints/docs.yaml b/netbox/project-static/select2-4.0.13/docs/themes/learn2/blueprints/docs.yaml similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/blueprints/docs.yaml rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/blueprints/docs.yaml diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/css-compiled/nucleus.css b/netbox/project-static/select2-4.0.13/docs/themes/learn2/css-compiled/nucleus.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/css-compiled/nucleus.css rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/css-compiled/nucleus.css diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/css-compiled/nucleus.css.map b/netbox/project-static/select2-4.0.13/docs/themes/learn2/css-compiled/nucleus.css.map similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/css-compiled/nucleus.css.map rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/css-compiled/nucleus.css.map diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/css-compiled/theme.css b/netbox/project-static/select2-4.0.13/docs/themes/learn2/css-compiled/theme.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/css-compiled/theme.css rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/css-compiled/theme.css diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/css-compiled/theme.css.map b/netbox/project-static/select2-4.0.13/docs/themes/learn2/css-compiled/theme.css.map similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/css-compiled/theme.css.map rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/css-compiled/theme.css.map diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/css/featherlight.min.css b/netbox/project-static/select2-4.0.13/docs/themes/learn2/css/featherlight.min.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/css/featherlight.min.css rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/css/featherlight.min.css diff --git a/netbox/project-static/font-awesome-4.7.0/css/font-awesome.min.css b/netbox/project-static/select2-4.0.13/docs/themes/learn2/css/font-awesome.min.css similarity index 100% rename from netbox/project-static/font-awesome-4.7.0/css/font-awesome.min.css rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/css/font-awesome.min.css diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/css/nucleus-ie10.css b/netbox/project-static/select2-4.0.13/docs/themes/learn2/css/nucleus-ie10.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/css/nucleus-ie10.css rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/css/nucleus-ie10.css diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/css/nucleus-ie9.css b/netbox/project-static/select2-4.0.13/docs/themes/learn2/css/nucleus-ie9.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/css/nucleus-ie9.css rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/css/nucleus-ie9.css diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/css/pure-0.5.0/grids-min.css b/netbox/project-static/select2-4.0.13/docs/themes/learn2/css/pure-0.5.0/grids-min.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/css/pure-0.5.0/grids-min.css rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/css/pure-0.5.0/grids-min.css diff --git a/netbox/project-static/font-awesome-4.7.0/fonts/fontawesome-webfont.eot b/netbox/project-static/select2-4.0.13/docs/themes/learn2/fonts/fontawesome-webfont.eot similarity index 100% rename from netbox/project-static/font-awesome-4.7.0/fonts/fontawesome-webfont.eot rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/fonts/fontawesome-webfont.eot diff --git a/netbox/project-static/font-awesome-4.7.0/fonts/fontawesome-webfont.svg b/netbox/project-static/select2-4.0.13/docs/themes/learn2/fonts/fontawesome-webfont.svg similarity index 100% rename from netbox/project-static/font-awesome-4.7.0/fonts/fontawesome-webfont.svg rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/fonts/fontawesome-webfont.svg diff --git a/netbox/project-static/font-awesome-4.7.0/fonts/fontawesome-webfont.ttf b/netbox/project-static/select2-4.0.13/docs/themes/learn2/fonts/fontawesome-webfont.ttf similarity index 100% rename from netbox/project-static/font-awesome-4.7.0/fonts/fontawesome-webfont.ttf rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/fonts/fontawesome-webfont.ttf diff --git a/netbox/project-static/font-awesome-4.7.0/fonts/fontawesome-webfont.woff b/netbox/project-static/select2-4.0.13/docs/themes/learn2/fonts/fontawesome-webfont.woff similarity index 100% rename from netbox/project-static/font-awesome-4.7.0/fonts/fontawesome-webfont.woff rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/fonts/fontawesome-webfont.woff diff --git a/netbox/project-static/font-awesome-4.7.0/fonts/fontawesome-webfont.woff2 b/netbox/project-static/select2-4.0.13/docs/themes/learn2/fonts/fontawesome-webfont.woff2 similarity index 100% rename from netbox/project-static/font-awesome-4.7.0/fonts/fontawesome-webfont.woff2 rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/fonts/fontawesome-webfont.woff2 diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/images/clippy.svg b/netbox/project-static/select2-4.0.13/docs/themes/learn2/images/clippy.svg similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/images/clippy.svg rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/images/clippy.svg diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/images/favicon.png b/netbox/project-static/select2-4.0.13/docs/themes/learn2/images/favicon.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/images/favicon.png rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/images/favicon.png diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/images/logo.png b/netbox/project-static/select2-4.0.13/docs/themes/learn2/images/logo.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/images/logo.png rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/images/logo.png diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/js/clipboard.min.js b/netbox/project-static/select2-4.0.13/docs/themes/learn2/js/clipboard.min.js similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/js/clipboard.min.js rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/js/clipboard.min.js diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/js/featherlight.min.js b/netbox/project-static/select2-4.0.13/docs/themes/learn2/js/featherlight.min.js similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/js/featherlight.min.js rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/js/featherlight.min.js diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/js/html5shiv-printshiv.min.js b/netbox/project-static/select2-4.0.13/docs/themes/learn2/js/html5shiv-printshiv.min.js similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/js/html5shiv-printshiv.min.js rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/js/html5shiv-printshiv.min.js diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/js/jquery.scrollbar.min.js b/netbox/project-static/select2-4.0.13/docs/themes/learn2/js/jquery.scrollbar.min.js similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/js/jquery.scrollbar.min.js rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/js/jquery.scrollbar.min.js diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/js/learn.js b/netbox/project-static/select2-4.0.13/docs/themes/learn2/js/learn.js similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/js/learn.js rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/js/learn.js diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/js/modernizr.custom.71422.js b/netbox/project-static/select2-4.0.13/docs/themes/learn2/js/modernizr.custom.71422.js similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/js/modernizr.custom.71422.js rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/js/modernizr.custom.71422.js diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/languages.yaml b/netbox/project-static/select2-4.0.13/docs/themes/learn2/languages.yaml similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/languages.yaml rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/languages.yaml diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/learn2.php b/netbox/project-static/select2-4.0.13/docs/themes/learn2/learn2.php similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/learn2.php rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/learn2.php diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/learn2.yaml b/netbox/project-static/select2-4.0.13/docs/themes/learn2/learn2.yaml similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/learn2.yaml rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/learn2.yaml diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/screenshot.jpg b/netbox/project-static/select2-4.0.13/docs/themes/learn2/screenshot.jpg similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/screenshot.jpg rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/screenshot.jpg diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss.sh b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss.sh similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss.sh rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss.sh diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/nucleus/_base.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/nucleus/_base.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/nucleus/_base.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/nucleus/_base.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/nucleus/_breakpoints.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/nucleus/_breakpoints.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/nucleus/_breakpoints.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/nucleus/_breakpoints.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/nucleus/_core.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/nucleus/_core.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/nucleus/_core.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/nucleus/_core.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/nucleus/_layout.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/nucleus/_layout.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/nucleus/_layout.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/nucleus/_layout.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/nucleus/_nav.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/nucleus/_nav.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/nucleus/_nav.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/nucleus/_nav.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/nucleus/_typography.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/nucleus/_typography.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/nucleus/_typography.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/nucleus/_typography.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/theme/_base.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/theme/_base.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/theme/_base.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/theme/_base.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/theme/_bullets.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/theme/_bullets.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/theme/_bullets.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/theme/_bullets.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/theme/_colors.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/theme/_colors.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/theme/_colors.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/theme/_colors.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/_core.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/_core.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/_core.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/_core.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/_flex.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/_flex.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/_flex.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/_flex.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/_forms.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/_forms.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/_forms.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/_forms.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/_typography.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/_typography.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/_typography.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/_typography.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/functions/_base.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/functions/_base.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/functions/_base.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/functions/_base.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/functions/_direction.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/functions/_direction.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/functions/_direction.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/functions/_direction.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/functions/_range.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/functions/_range.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/functions/_range.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/functions/_range.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/mixins/_base.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/mixins/_base.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/mixins/_base.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/mixins/_base.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/mixins/_breakpoints.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/mixins/_breakpoints.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/mixins/_breakpoints.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/mixins/_breakpoints.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/mixins/_utilities.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/mixins/_utilities.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/mixins/_utilities.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/mixins/_utilities.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/particles/_align-text.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/particles/_align-text.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/particles/_align-text.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/particles/_align-text.scss diff --git a/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/particles/_visibility.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/particles/_visibility.scss new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_bullets.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_bullets.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_bullets.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_bullets.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_buttons.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_buttons.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_buttons.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_buttons.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_configuration.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_configuration.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_configuration.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_configuration.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_core.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_core.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_core.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_core.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_custom.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_custom.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_custom.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_custom.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_fonts.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_fonts.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_fonts.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_fonts.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_forms.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_forms.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_forms.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_forms.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_header.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_header.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_header.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_header.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_main.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_main.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_main.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_main.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_nav.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_nav.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_nav.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_nav.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_scrollbar.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_scrollbar.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_scrollbar.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_scrollbar.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_tables.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_tables.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_tables.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_tables.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_tooltips.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_tooltips.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_tooltips.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_tooltips.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_typography.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_typography.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_typography.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_typography.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/modules/_base.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/modules/_base.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/modules/_base.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/modules/_base.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/modules/_buttons.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/modules/_buttons.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/modules/_buttons.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/modules/_buttons.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/_bourbon-deprecated-upcoming.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/_bourbon-deprecated-upcoming.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/_bourbon-deprecated-upcoming.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/_bourbon-deprecated-upcoming.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/_bourbon.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/_bourbon.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/_bourbon.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/_bourbon.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_button.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_button.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_button.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_button.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_clearfix.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_clearfix.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_clearfix.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_clearfix.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_directional-values.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_directional-values.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_directional-values.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_directional-values.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_ellipsis.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_ellipsis.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_ellipsis.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_ellipsis.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_font-family.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_font-family.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_font-family.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_font-family.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_hide-text.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_hide-text.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_hide-text.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_hide-text.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_html5-input-types.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_html5-input-types.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_html5-input-types.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_html5-input-types.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_position.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_position.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_position.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_position.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_prefixer.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_prefixer.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_prefixer.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_prefixer.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_rem.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_rem.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_rem.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_rem.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_retina-image.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_retina-image.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_retina-image.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_retina-image.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_size.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_size.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_size.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_size.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_timing-functions.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_timing-functions.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_timing-functions.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_timing-functions.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_triangle.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_triangle.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_triangle.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_triangle.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_word-wrap.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_word-wrap.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_word-wrap.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_word-wrap.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_animation.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_animation.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_animation.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_animation.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_appearance.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_appearance.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_appearance.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_appearance.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_backface-visibility.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_backface-visibility.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_backface-visibility.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_backface-visibility.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_background-image.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_background-image.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_background-image.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_background-image.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_background.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_background.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_background.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_background.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_border-image.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_border-image.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_border-image.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_border-image.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_border-radius.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_border-radius.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_border-radius.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_border-radius.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_box-sizing.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_box-sizing.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_box-sizing.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_box-sizing.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_calc.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_calc.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_calc.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_calc.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_columns.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_columns.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_columns.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_columns.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_filter.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_filter.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_filter.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_filter.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_flex-box.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_flex-box.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_flex-box.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_flex-box.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_font-face.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_font-face.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_font-face.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_font-face.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_font-feature-settings.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_font-feature-settings.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_font-feature-settings.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_font-feature-settings.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_hidpi-media-query.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_hidpi-media-query.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_hidpi-media-query.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_hidpi-media-query.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_hyphens.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_hyphens.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_hyphens.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_hyphens.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_image-rendering.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_image-rendering.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_image-rendering.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_image-rendering.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_keyframes.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_keyframes.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_keyframes.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_keyframes.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_linear-gradient.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_linear-gradient.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_linear-gradient.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_linear-gradient.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_perspective.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_perspective.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_perspective.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_perspective.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_placeholder.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_placeholder.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_placeholder.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_placeholder.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_radial-gradient.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_radial-gradient.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_radial-gradient.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_radial-gradient.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_transform.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_transform.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_transform.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_transform.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_transition.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_transition.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_transition.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_transition.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_user-select.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_user-select.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_user-select.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_user-select.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_assign.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_assign.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_assign.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_assign.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_color-lightness.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_color-lightness.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_color-lightness.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_color-lightness.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_flex-grid.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_flex-grid.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_flex-grid.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_flex-grid.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_golden-ratio.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_golden-ratio.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_golden-ratio.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_golden-ratio.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_grid-width.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_grid-width.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_grid-width.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_grid-width.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_modular-scale.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_modular-scale.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_modular-scale.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_modular-scale.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_px-to-em.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_px-to-em.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_px-to-em.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_px-to-em.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_px-to-rem.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_px-to-rem.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_px-to-rem.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_px-to-rem.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_strip-units.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_strip-units.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_strip-units.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_strip-units.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_tint-shade.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_tint-shade.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_tint-shade.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_tint-shade.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_transition-property-name.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_transition-property-name.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_transition-property-name.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_transition-property-name.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_unpack.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_unpack.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_unpack.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_unpack.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_convert-units.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_convert-units.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_convert-units.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_convert-units.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_gradient-positions-parser.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_gradient-positions-parser.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_gradient-positions-parser.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_gradient-positions-parser.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_is-num.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_is-num.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_is-num.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_is-num.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_linear-angle-parser.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_linear-angle-parser.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_linear-angle-parser.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_linear-angle-parser.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_linear-gradient-parser.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_linear-gradient-parser.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_linear-gradient-parser.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_linear-gradient-parser.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_linear-positions-parser.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_linear-positions-parser.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_linear-positions-parser.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_linear-positions-parser.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_linear-side-corner-parser.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_linear-side-corner-parser.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_linear-side-corner-parser.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_linear-side-corner-parser.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_radial-arg-parser.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_radial-arg-parser.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_radial-arg-parser.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_radial-arg-parser.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_radial-gradient-parser.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_radial-gradient-parser.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_radial-gradient-parser.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_radial-gradient-parser.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_radial-positions-parser.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_radial-positions-parser.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_radial-positions-parser.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_radial-positions-parser.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_render-gradients.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_render-gradients.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_render-gradients.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_render-gradients.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_shape-size-stripper.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_shape-size-stripper.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_shape-size-stripper.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_shape-size-stripper.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_str-to-num.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_str-to-num.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_str-to-num.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_str-to-num.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/settings/_prefixer.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/settings/_prefixer.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/settings/_prefixer.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/settings/_prefixer.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/settings/_px-to-em.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/settings/_px-to-em.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/settings/_px-to-em.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/settings/_px-to-em.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/_color-schemer.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/_color-schemer.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/_color-schemer.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/_color-schemer.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_cmyk.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_cmyk.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_cmyk.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_cmyk.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_color-adjustments.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_color-adjustments.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_color-adjustments.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_color-adjustments.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_color-schemer.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_color-schemer.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_color-schemer.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_color-schemer.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_colorblind.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_colorblind.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_colorblind.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_colorblind.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_comparison.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_comparison.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_comparison.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_comparison.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_equalize.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_equalize.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_equalize.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_equalize.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_harmonize.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_harmonize.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_harmonize.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_harmonize.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_interpolation.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_interpolation.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_interpolation.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_interpolation.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_mix.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_mix.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_mix.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_mix.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_mixins.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_mixins.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_mixins.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_mixins.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_ryb.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_ryb.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_ryb.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_ryb.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_tint-shade.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_tint-shade.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_tint-shade.scss rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_tint-shade.scss diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/chapter.html.twig b/netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/chapter.html.twig similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/chapter.html.twig rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/chapter.html.twig diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/default.html.twig b/netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/default.html.twig similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/default.html.twig rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/default.html.twig diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/docs.html.twig b/netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/docs.html.twig similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/docs.html.twig rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/docs.html.twig diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/error.html.twig b/netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/error.html.twig similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/error.html.twig rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/error.html.twig diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/partials/analytics.html.twig b/netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/partials/analytics.html.twig similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/partials/analytics.html.twig rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/partials/analytics.html.twig diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/partials/base.html.twig b/netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/partials/base.html.twig similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/partials/base.html.twig rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/partials/base.html.twig diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/partials/github_link.html.twig b/netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/partials/github_link.html.twig similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/partials/github_link.html.twig rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/partials/github_link.html.twig diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/partials/github_note.html.twig b/netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/partials/github_note.html.twig similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/partials/github_note.html.twig rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/partials/github_note.html.twig diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/partials/logo.html.twig b/netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/partials/logo.html.twig similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/partials/logo.html.twig rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/partials/logo.html.twig diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/partials/metadata.html.twig b/netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/partials/metadata.html.twig similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/partials/metadata.html.twig rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/partials/metadata.html.twig diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/partials/page.html.twig b/netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/partials/page.html.twig similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/partials/page.html.twig rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/partials/page.html.twig diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/partials/sidebar.html.twig b/netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/partials/sidebar.html.twig similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/partials/sidebar.html.twig rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/partials/sidebar.html.twig diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/thumbnail.jpg b/netbox/project-static/select2-4.0.13/docs/themes/learn2/thumbnail.jpg similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/thumbnail.jpg rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/thumbnail.jpg diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/css/s2-docs.css b/netbox/project-static/select2-4.0.13/docs/themes/site/css/s2-docs.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/site/css/s2-docs.css rename to netbox/project-static/select2-4.0.13/docs/themes/site/css/s2-docs.css diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/css/theme.css b/netbox/project-static/select2-4.0.13/docs/themes/site/css/theme.css similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/site/css/theme.css rename to netbox/project-static/select2-4.0.13/docs/themes/site/css/theme.css diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/images/android-chrome-36x36.png b/netbox/project-static/select2-4.0.13/docs/themes/site/images/android-chrome-36x36.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/site/images/android-chrome-36x36.png rename to netbox/project-static/select2-4.0.13/docs/themes/site/images/android-chrome-36x36.png diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/images/android-chrome-48x48.png b/netbox/project-static/select2-4.0.13/docs/themes/site/images/android-chrome-48x48.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/site/images/android-chrome-48x48.png rename to netbox/project-static/select2-4.0.13/docs/themes/site/images/android-chrome-48x48.png diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/images/android-chrome-72x72.png b/netbox/project-static/select2-4.0.13/docs/themes/site/images/android-chrome-72x72.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/site/images/android-chrome-72x72.png rename to netbox/project-static/select2-4.0.13/docs/themes/site/images/android-chrome-72x72.png diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/images/apple-touch-icon-57x57.png b/netbox/project-static/select2-4.0.13/docs/themes/site/images/apple-touch-icon-57x57.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/site/images/apple-touch-icon-57x57.png rename to netbox/project-static/select2-4.0.13/docs/themes/site/images/apple-touch-icon-57x57.png diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/images/apple-touch-icon-60x60.png b/netbox/project-static/select2-4.0.13/docs/themes/site/images/apple-touch-icon-60x60.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/site/images/apple-touch-icon-60x60.png rename to netbox/project-static/select2-4.0.13/docs/themes/site/images/apple-touch-icon-60x60.png diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/images/apple-touch-icon-72x72.png b/netbox/project-static/select2-4.0.13/docs/themes/site/images/apple-touch-icon-72x72.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/site/images/apple-touch-icon-72x72.png rename to netbox/project-static/select2-4.0.13/docs/themes/site/images/apple-touch-icon-72x72.png diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/images/apple-touch-icon-precomposed.png b/netbox/project-static/select2-4.0.13/docs/themes/site/images/apple-touch-icon-precomposed.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/site/images/apple-touch-icon-precomposed.png rename to netbox/project-static/select2-4.0.13/docs/themes/site/images/apple-touch-icon-precomposed.png diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/images/apple-touch-icon.png b/netbox/project-static/select2-4.0.13/docs/themes/site/images/apple-touch-icon.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/site/images/apple-touch-icon.png rename to netbox/project-static/select2-4.0.13/docs/themes/site/images/apple-touch-icon.png diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/images/favicon-16x16.png b/netbox/project-static/select2-4.0.13/docs/themes/site/images/favicon-16x16.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/site/images/favicon-16x16.png rename to netbox/project-static/select2-4.0.13/docs/themes/site/images/favicon-16x16.png diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/images/favicon-32x32.png b/netbox/project-static/select2-4.0.13/docs/themes/site/images/favicon-32x32.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/site/images/favicon-32x32.png rename to netbox/project-static/select2-4.0.13/docs/themes/site/images/favicon-32x32.png diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/images/favicon.ico b/netbox/project-static/select2-4.0.13/docs/themes/site/images/favicon.ico similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/site/images/favicon.ico rename to netbox/project-static/select2-4.0.13/docs/themes/site/images/favicon.ico diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/images/favicon.png b/netbox/project-static/select2-4.0.13/docs/themes/site/images/favicon.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/site/images/favicon.png rename to netbox/project-static/select2-4.0.13/docs/themes/site/images/favicon.png diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/images/manifest.json b/netbox/project-static/select2-4.0.13/docs/themes/site/images/manifest.json similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/site/images/manifest.json rename to netbox/project-static/select2-4.0.13/docs/themes/site/images/manifest.json diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/images/mstile-150x150.png b/netbox/project-static/select2-4.0.13/docs/themes/site/images/mstile-150x150.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/site/images/mstile-150x150.png rename to netbox/project-static/select2-4.0.13/docs/themes/site/images/mstile-150x150.png diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/images/mstile-310x150.png b/netbox/project-static/select2-4.0.13/docs/themes/site/images/mstile-310x150.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/site/images/mstile-310x150.png rename to netbox/project-static/select2-4.0.13/docs/themes/site/images/mstile-310x150.png diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/images/mstile-70x70.png b/netbox/project-static/select2-4.0.13/docs/themes/site/images/mstile-70x70.png similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/site/images/mstile-70x70.png rename to netbox/project-static/select2-4.0.13/docs/themes/site/images/mstile-70x70.png diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/images/safari-pinned-tab.svg b/netbox/project-static/select2-4.0.13/docs/themes/site/images/safari-pinned-tab.svg similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/site/images/safari-pinned-tab.svg rename to netbox/project-static/select2-4.0.13/docs/themes/site/images/safari-pinned-tab.svg diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/js/data-fill-from.js b/netbox/project-static/select2-4.0.13/docs/themes/site/js/data-fill-from.js similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/site/js/data-fill-from.js rename to netbox/project-static/select2-4.0.13/docs/themes/site/js/data-fill-from.js diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/site.yaml b/netbox/project-static/select2-4.0.13/docs/themes/site/site.yaml similarity index 65% rename from netbox/project-static/select2-4.0.12/docs/themes/site/site.yaml rename to netbox/project-static/select2-4.0.13/docs/themes/site/site.yaml index f1ecec179..a486ca025 100644 --- a/netbox/project-static/select2-4.0.12/docs/themes/site/site.yaml +++ b/netbox/project-static/select2-4.0.13/docs/themes/site/site.yaml @@ -9,5 +9,5 @@ streams: google_analytics_code: UA-57144786-2 github: position: top # top | bottom | off - tree: https://github.com/select2/docs/blob/develop/ - commits: https://github.com/select2/docs/commits/develop/ + tree: https://github.com/select2/select2/blob/develop/docs/ + commits: https://github.com/select2/select2/commits/develop/docs/ diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/templates/partials/base.html.twig b/netbox/project-static/select2-4.0.13/docs/themes/site/templates/partials/base.html.twig similarity index 98% rename from netbox/project-static/select2-4.0.12/docs/themes/site/templates/partials/base.html.twig rename to netbox/project-static/select2-4.0.13/docs/themes/site/templates/partials/base.html.twig index d26baa401..7cde0c918 100644 --- a/netbox/project-static/select2-4.0.12/docs/themes/site/templates/partials/base.html.twig +++ b/netbox/project-static/select2-4.0.13/docs/themes/site/templates/partials/base.html.twig @@ -17,7 +17,7 @@ {% do assets.addCss('theme://css/custom.css',100) %} {% do assets.addCss('theme://css/font-awesome.min.css',100) %} {% do assets.addCss('theme://css/featherlight.min.css') %} - {% do assets.addCss('https://cdn.jsdelivr.net/npm/select2@4.0.12/dist/css/select2.min.css') %} + {% do assets.addCss('https://cdn.jsdelivr.net/npm/select2@4.0.13/dist/css/select2.min.css') %} {% do assets.addCss('theme://css/s2-docs.css', 100) %} {% do assets.addCss('theme://css/theme.css',100) %} @@ -33,7 +33,7 @@ {% block javascripts %} {% do assets.addJs('jquery',101) %} {% do assets.addJs('theme://js/modernizr.custom.71422.js',100) %} - {% do assets.addJs('https://cdn.jsdelivr.net/npm/select2@4.0.12/dist/js/select2.full.min.js', 100) %} + {% do assets.addJs('https://cdn.jsdelivr.net/npm/select2@4.0.13/dist/js/select2.full.min.js', 100) %} {% do assets.addJs('https://cdn.rawgit.com/google/code-prettify/master/loader/run_prettify.js', 100) %} {% do assets.addJs('theme://js/featherlight.min.js') %} {% do assets.addJs('theme://js/clipboard.min.js') %} diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/templates/partials/js/source-states.html.twig b/netbox/project-static/select2-4.0.13/docs/themes/site/templates/partials/js/source-states.html.twig similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/site/templates/partials/js/source-states.html.twig rename to netbox/project-static/select2-4.0.13/docs/themes/site/templates/partials/js/source-states.html.twig diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/templates/partials/logo.html.twig b/netbox/project-static/select2-4.0.13/docs/themes/site/templates/partials/logo.html.twig similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/site/templates/partials/logo.html.twig rename to netbox/project-static/select2-4.0.13/docs/themes/site/templates/partials/logo.html.twig diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/templates/partials/sidebar.html.twig b/netbox/project-static/select2-4.0.13/docs/themes/site/templates/partials/sidebar.html.twig similarity index 100% rename from netbox/project-static/select2-4.0.12/docs/themes/site/templates/partials/sidebar.html.twig rename to netbox/project-static/select2-4.0.13/docs/themes/site/templates/partials/sidebar.html.twig diff --git a/netbox/project-static/select2-4.0.12/package.json b/netbox/project-static/select2-4.0.13/package.json similarity index 98% rename from netbox/project-static/select2-4.0.12/package.json rename to netbox/project-static/select2-4.0.13/package.json index c7c3fb0a3..30135ba94 100644 --- a/netbox/project-static/select2-4.0.12/package.json +++ b/netbox/project-static/select2-4.0.13/package.json @@ -39,7 +39,7 @@ "src", "dist" ], - "version": "4.0.12", + "version": "4.0.13", "jspm": { "main": "js/select2", "directories": { diff --git a/netbox/project-static/select2-4.0.12/src/js/banner.end.js b/netbox/project-static/select2-4.0.13/src/js/banner.end.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/banner.end.js rename to netbox/project-static/select2-4.0.13/src/js/banner.end.js diff --git a/netbox/project-static/select2-4.0.12/src/js/banner.start.js b/netbox/project-static/select2-4.0.13/src/js/banner.start.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/banner.start.js rename to netbox/project-static/select2-4.0.13/src/js/banner.start.js diff --git a/netbox/project-static/select2-4.0.12/src/js/jquery.mousewheel.shim.js b/netbox/project-static/select2-4.0.13/src/js/jquery.mousewheel.shim.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/jquery.mousewheel.shim.js rename to netbox/project-static/select2-4.0.13/src/js/jquery.mousewheel.shim.js diff --git a/netbox/project-static/select2-4.0.12/src/js/jquery.select2.js b/netbox/project-static/select2-4.0.13/src/js/jquery.select2.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/jquery.select2.js rename to netbox/project-static/select2-4.0.13/src/js/jquery.select2.js diff --git a/netbox/project-static/select2-4.0.12/src/js/jquery.shim.js b/netbox/project-static/select2-4.0.13/src/js/jquery.shim.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/jquery.shim.js rename to netbox/project-static/select2-4.0.13/src/js/jquery.shim.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/compat/containerCss.js b/netbox/project-static/select2-4.0.13/src/js/select2/compat/containerCss.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/compat/containerCss.js rename to netbox/project-static/select2-4.0.13/src/js/select2/compat/containerCss.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/compat/dropdownCss.js b/netbox/project-static/select2-4.0.13/src/js/select2/compat/dropdownCss.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/compat/dropdownCss.js rename to netbox/project-static/select2-4.0.13/src/js/select2/compat/dropdownCss.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/compat/initSelection.js b/netbox/project-static/select2-4.0.13/src/js/select2/compat/initSelection.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/compat/initSelection.js rename to netbox/project-static/select2-4.0.13/src/js/select2/compat/initSelection.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/compat/inputData.js b/netbox/project-static/select2-4.0.13/src/js/select2/compat/inputData.js similarity index 94% rename from netbox/project-static/select2-4.0.12/src/js/select2/compat/inputData.js rename to netbox/project-static/select2-4.0.13/src/js/select2/compat/inputData.js index 6e1dee261..cd58c5c94 100644 --- a/netbox/project-static/select2-4.0.12/src/js/select2/compat/inputData.js +++ b/netbox/project-static/select2-4.0.13/src/js/select2/compat/inputData.js @@ -65,13 +65,13 @@ define([ }); this.$element.val(data.id); - this.$element.trigger('change'); + this.$element.trigger('input').trigger('change'); } else { var value = this.$element.val(); value += this._valueSeparator + data.id; this.$element.val(value); - this.$element.trigger('change'); + this.$element.trigger('input').trigger('change'); } }; @@ -94,7 +94,7 @@ define([ } self.$element.val(values.join(self._valueSeparator)); - self.$element.trigger('change'); + self.$element.trigger('input').trigger('change'); }); }; diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/compat/matcher.js b/netbox/project-static/select2-4.0.13/src/js/select2/compat/matcher.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/compat/matcher.js rename to netbox/project-static/select2-4.0.13/src/js/select2/compat/matcher.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/compat/query.js b/netbox/project-static/select2-4.0.13/src/js/select2/compat/query.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/compat/query.js rename to netbox/project-static/select2-4.0.13/src/js/select2/compat/query.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/compat/utils.js b/netbox/project-static/select2-4.0.13/src/js/select2/compat/utils.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/compat/utils.js rename to netbox/project-static/select2-4.0.13/src/js/select2/compat/utils.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/core.js b/netbox/project-static/select2-4.0.13/src/js/select2/core.js similarity index 91% rename from netbox/project-static/select2-4.0.12/src/js/select2/core.js rename to netbox/project-static/select2-4.0.13/src/js/select2/core.js index 92084143a..831691f81 100644 --- a/netbox/project-static/select2-4.0.12/src/js/select2/core.js +++ b/netbox/project-static/select2-4.0.13/src/js/select2/core.js @@ -208,8 +208,8 @@ define([ if (observer != null) { this._observer = new observer(function (mutations) { - $.each(mutations, self._syncA); - $.each(mutations, self._syncS); + self._syncA(); + self._syncS(null, mutations); }); this._observer.observe(this.$element[0], { attributes: true, @@ -331,7 +331,7 @@ define([ if (self.isOpen()) { if (key === KEYS.ESC || key === KEYS.TAB || (key === KEYS.UP && evt.altKey)) { - self.close(); + self.close(evt); evt.preventDefault(); } else if (key === KEYS.ENTER) { @@ -365,7 +365,7 @@ define([ Select2.prototype._syncAttributes = function () { this.options.set('disabled', this.$element.prop('disabled')); - if (this.options.get('disabled')) { + if (this.isDisabled()) { if (this.isOpen()) { this.close(); } @@ -376,7 +376,7 @@ define([ } }; - Select2.prototype._syncSubtree = function (evt, mutations) { + Select2.prototype._isChangeMutation = function (evt, mutations) { var changed = false; var self = this; @@ -404,7 +404,22 @@ define([ } } else if (mutations.removedNodes && mutations.removedNodes.length > 0) { changed = true; + } else if ($.isArray(mutations)) { + $.each(mutations, function(evt, mutation) { + if (self._isChangeMutation(evt, mutation)) { + // We've found a change mutation. + // Let's escape from the loop and continue + changed = true; + return false; + } + }); } + return changed; + }; + + Select2.prototype._syncSubtree = function (evt, mutations) { + var changed = this._isChangeMutation(evt, mutations); + var self = this; // Only re-pull the data if we think there is a change if (changed) { @@ -455,7 +470,7 @@ define([ }; Select2.prototype.toggleDropdown = function () { - if (this.options.get('disabled')) { + if (this.isDisabled()) { return; } @@ -471,15 +486,40 @@ define([ return; } + if (this.isDisabled()) { + return; + } + this.trigger('query', {}); }; - Select2.prototype.close = function () { + Select2.prototype.close = function (evt) { if (!this.isOpen()) { return; } - this.trigger('close', {}); + this.trigger('close', { originalEvent : evt }); + }; + + /** + * Helper method to abstract the "enabled" (not "disabled") state of this + * object. + * + * @return {true} if the instance is not disabled. + * @return {false} if the instance is disabled. + */ + Select2.prototype.isEnabled = function () { + return !this.isDisabled(); + }; + + /** + * Helper method to abstract the "disabled" state of this object. + * + * @return {true} if the disabled option is true. + * @return {false} if the disabled option is false. + */ + Select2.prototype.isDisabled = function () { + return this.options.get('disabled'); }; Select2.prototype.isOpen = function () { @@ -556,7 +596,7 @@ define([ }); } - this.$element.val(newVal).trigger('change'); + this.$element.val(newVal).trigger('input').trigger('change'); }; Select2.prototype.destroy = function () { diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/data/ajax.js b/netbox/project-static/select2-4.0.13/src/js/select2/data/ajax.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/data/ajax.js rename to netbox/project-static/select2-4.0.13/src/js/select2/data/ajax.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/data/array.js b/netbox/project-static/select2-4.0.13/src/js/select2/data/array.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/data/array.js rename to netbox/project-static/select2-4.0.13/src/js/select2/data/array.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/data/base.js b/netbox/project-static/select2-4.0.13/src/js/select2/data/base.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/data/base.js rename to netbox/project-static/select2-4.0.13/src/js/select2/data/base.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/data/maximumInputLength.js b/netbox/project-static/select2-4.0.13/src/js/select2/data/maximumInputLength.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/data/maximumInputLength.js rename to netbox/project-static/select2-4.0.13/src/js/select2/data/maximumInputLength.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/data/maximumSelectionLength.js b/netbox/project-static/select2-4.0.13/src/js/select2/data/maximumSelectionLength.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/data/maximumSelectionLength.js rename to netbox/project-static/select2-4.0.13/src/js/select2/data/maximumSelectionLength.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/data/minimumInputLength.js b/netbox/project-static/select2-4.0.13/src/js/select2/data/minimumInputLength.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/data/minimumInputLength.js rename to netbox/project-static/select2-4.0.13/src/js/select2/data/minimumInputLength.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/data/select.js b/netbox/project-static/select2-4.0.13/src/js/select2/data/select.js similarity index 95% rename from netbox/project-static/select2-4.0.12/src/js/select2/data/select.js rename to netbox/project-static/select2-4.0.13/src/js/select2/data/select.js index a897198b5..a38473502 100644 --- a/netbox/project-static/select2-4.0.12/src/js/select2/data/select.js +++ b/netbox/project-static/select2-4.0.13/src/js/select2/data/select.js @@ -36,7 +36,7 @@ define([ if ($(data.element).is('option')) { data.element.selected = true; - this.$element.trigger('change'); + this.$element.trigger('input').trigger('change'); return; } @@ -57,13 +57,13 @@ define([ } self.$element.val(val); - self.$element.trigger('change'); + self.$element.trigger('input').trigger('change'); }); } else { var val = data.id; this.$element.val(val); - this.$element.trigger('change'); + this.$element.trigger('input').trigger('change'); } }; @@ -79,7 +79,7 @@ define([ if ($(data.element).is('option')) { data.element.selected = false; - this.$element.trigger('change'); + this.$element.trigger('input').trigger('change'); return; } @@ -97,7 +97,7 @@ define([ self.$element.val(val); - self.$element.trigger('change'); + self.$element.trigger('input').trigger('change'); }); }; diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/data/tags.js b/netbox/project-static/select2-4.0.13/src/js/select2/data/tags.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/data/tags.js rename to netbox/project-static/select2-4.0.13/src/js/select2/data/tags.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/data/tokenizer.js b/netbox/project-static/select2-4.0.13/src/js/select2/data/tokenizer.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/data/tokenizer.js rename to netbox/project-static/select2-4.0.13/src/js/select2/data/tokenizer.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/defaults.js b/netbox/project-static/select2-4.0.13/src/js/select2/defaults.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/defaults.js rename to netbox/project-static/select2-4.0.13/src/js/select2/defaults.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/diacritics.js b/netbox/project-static/select2-4.0.13/src/js/select2/diacritics.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/diacritics.js rename to netbox/project-static/select2-4.0.13/src/js/select2/diacritics.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/dropdown.js b/netbox/project-static/select2-4.0.13/src/js/select2/dropdown.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/dropdown.js rename to netbox/project-static/select2-4.0.13/src/js/select2/dropdown.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/dropdown/attachBody.js b/netbox/project-static/select2-4.0.13/src/js/select2/dropdown/attachBody.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/dropdown/attachBody.js rename to netbox/project-static/select2-4.0.13/src/js/select2/dropdown/attachBody.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/dropdown/attachContainer.js b/netbox/project-static/select2-4.0.13/src/js/select2/dropdown/attachContainer.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/dropdown/attachContainer.js rename to netbox/project-static/select2-4.0.13/src/js/select2/dropdown/attachContainer.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/dropdown/closeOnSelect.js b/netbox/project-static/select2-4.0.13/src/js/select2/dropdown/closeOnSelect.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/dropdown/closeOnSelect.js rename to netbox/project-static/select2-4.0.13/src/js/select2/dropdown/closeOnSelect.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/dropdown/hidePlaceholder.js b/netbox/project-static/select2-4.0.13/src/js/select2/dropdown/hidePlaceholder.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/dropdown/hidePlaceholder.js rename to netbox/project-static/select2-4.0.13/src/js/select2/dropdown/hidePlaceholder.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/dropdown/infiniteScroll.js b/netbox/project-static/select2-4.0.13/src/js/select2/dropdown/infiniteScroll.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/dropdown/infiniteScroll.js rename to netbox/project-static/select2-4.0.13/src/js/select2/dropdown/infiniteScroll.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/dropdown/minimumResultsForSearch.js b/netbox/project-static/select2-4.0.13/src/js/select2/dropdown/minimumResultsForSearch.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/dropdown/minimumResultsForSearch.js rename to netbox/project-static/select2-4.0.13/src/js/select2/dropdown/minimumResultsForSearch.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/dropdown/search.js b/netbox/project-static/select2-4.0.13/src/js/select2/dropdown/search.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/dropdown/search.js rename to netbox/project-static/select2-4.0.13/src/js/select2/dropdown/search.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/dropdown/selectOnClose.js b/netbox/project-static/select2-4.0.13/src/js/select2/dropdown/selectOnClose.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/dropdown/selectOnClose.js rename to netbox/project-static/select2-4.0.13/src/js/select2/dropdown/selectOnClose.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/dropdown/stopPropagation.js b/netbox/project-static/select2-4.0.13/src/js/select2/dropdown/stopPropagation.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/dropdown/stopPropagation.js rename to netbox/project-static/select2-4.0.13/src/js/select2/dropdown/stopPropagation.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/af.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/af.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/af.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/af.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/ar.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/ar.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/ar.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/ar.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/az.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/az.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/az.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/az.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/bg.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/bg.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/bg.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/bg.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/bn.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/bn.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/bn.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/bn.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/bs.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/bs.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/bs.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/bs.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/ca.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/ca.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/ca.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/ca.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/cs.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/cs.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/cs.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/cs.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/da.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/da.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/da.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/da.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/de.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/de.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/de.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/de.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/dsb.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/dsb.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/dsb.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/dsb.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/el.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/el.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/el.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/el.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/en.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/en.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/en.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/en.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/es.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/es.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/es.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/es.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/et.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/et.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/et.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/et.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/eu.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/eu.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/eu.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/eu.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/fa.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/fa.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/fa.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/fa.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/fi.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/fi.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/fi.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/fi.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/fr.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/fr.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/fr.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/fr.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/gl.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/gl.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/gl.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/gl.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/he.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/he.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/he.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/he.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/hi.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/hi.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/hi.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/hi.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/hr.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/hr.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/hr.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/hr.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/hsb.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/hsb.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/hsb.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/hsb.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/hu.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/hu.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/hu.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/hu.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/hy.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/hy.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/hy.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/hy.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/id.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/id.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/id.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/id.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/is.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/is.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/is.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/is.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/it.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/it.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/it.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/it.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/ja.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/ja.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/ja.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/ja.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/ka.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/ka.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/ka.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/ka.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/km.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/km.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/km.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/km.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/ko.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/ko.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/ko.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/ko.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/lt.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/lt.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/lt.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/lt.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/lv.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/lv.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/lv.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/lv.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/mk.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/mk.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/mk.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/mk.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/ms.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/ms.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/ms.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/ms.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/nb.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/nb.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/nb.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/nb.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/ne.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/ne.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/ne.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/ne.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/nl.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/nl.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/nl.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/nl.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/pl.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/pl.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/pl.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/pl.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/ps.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/ps.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/ps.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/ps.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/pt-BR.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/pt-BR.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/pt-BR.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/pt-BR.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/pt.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/pt.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/pt.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/pt.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/ro.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/ro.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/ro.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/ro.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/ru.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/ru.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/ru.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/ru.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/sk.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/sk.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/sk.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/sk.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/sl.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/sl.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/sl.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/sl.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/sq.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/sq.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/sq.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/sq.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/sr-Cyrl.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/sr-Cyrl.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/sr-Cyrl.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/sr-Cyrl.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/sr.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/sr.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/sr.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/sr.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/sv.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/sv.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/sv.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/sv.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/th.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/th.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/th.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/th.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/tk.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/tk.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/tk.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/tk.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/tr.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/tr.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/tr.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/tr.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/uk.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/uk.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/uk.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/uk.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/vi.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/vi.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/vi.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/vi.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/zh-CN.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/zh-CN.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/zh-CN.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/zh-CN.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/zh-TW.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/zh-TW.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/zh-TW.js rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/zh-TW.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/keys.js b/netbox/project-static/select2-4.0.13/src/js/select2/keys.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/keys.js rename to netbox/project-static/select2-4.0.13/src/js/select2/keys.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/options.js b/netbox/project-static/select2-4.0.13/src/js/select2/options.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/options.js rename to netbox/project-static/select2-4.0.13/src/js/select2/options.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/results.js b/netbox/project-static/select2-4.0.13/src/js/select2/results.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/results.js rename to netbox/project-static/select2-4.0.13/src/js/select2/results.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/selection/allowClear.js b/netbox/project-static/select2-4.0.13/src/js/select2/selection/allowClear.js similarity index 96% rename from netbox/project-static/select2-4.0.12/src/js/select2/selection/allowClear.js rename to netbox/project-static/select2-4.0.13/src/js/select2/selection/allowClear.js index 0de5b9bbb..7e6a32f10 100644 --- a/netbox/project-static/select2-4.0.12/src/js/select2/selection/allowClear.js +++ b/netbox/project-static/select2-4.0.13/src/js/select2/selection/allowClear.js @@ -31,7 +31,7 @@ define([ AllowClear.prototype._handleClear = function (_, evt) { // Ignore the event if it is disabled - if (this.options.get('disabled')) { + if (this.isDisabled()) { return; } @@ -74,7 +74,7 @@ define([ } } - this.$element.trigger('change'); + this.$element.trigger('input').trigger('change'); this.trigger('toggle', {}); }; @@ -97,7 +97,7 @@ define([ return; } - var removeAll = this.options.get('translations').get('removeAllItems'); + var removeAll = this.options.get('translations').get('removeAllItems'); var $remove = $( '' + diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/selection/base.js b/netbox/project-static/select2-4.0.13/src/js/select2/selection/base.js similarity index 87% rename from netbox/project-static/select2-4.0.12/src/js/select2/selection/base.js rename to netbox/project-static/select2-4.0.13/src/js/select2/selection/base.js index f3999b831..ed9c50d32 100644 --- a/netbox/project-static/select2-4.0.12/src/js/select2/selection/base.js +++ b/netbox/project-static/select2-4.0.13/src/js/select2/selection/base.js @@ -153,5 +153,26 @@ define([ throw new Error('The `update` method must be defined in child classes.'); }; + /** + * Helper method to abstract the "enabled" (not "disabled") state of this + * object. + * + * @return {true} if the instance is not disabled. + * @return {false} if the instance is disabled. + */ + BaseSelection.prototype.isEnabled = function () { + return !this.isDisabled(); + }; + + /** + * Helper method to abstract the "disabled" state of this object. + * + * @return {true} if the disabled option is true. + * @return {false} if the disabled option is false. + */ + BaseSelection.prototype.isDisabled = function () { + return this.options.get('disabled'); + }; + return BaseSelection; }); diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/selection/clickMask.js b/netbox/project-static/select2-4.0.13/src/js/select2/selection/clickMask.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/selection/clickMask.js rename to netbox/project-static/select2-4.0.13/src/js/select2/selection/clickMask.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/selection/eventRelay.js b/netbox/project-static/select2-4.0.13/src/js/select2/selection/eventRelay.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/selection/eventRelay.js rename to netbox/project-static/select2-4.0.13/src/js/select2/selection/eventRelay.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/selection/multiple.js b/netbox/project-static/select2-4.0.13/src/js/select2/selection/multiple.js similarity index 98% rename from netbox/project-static/select2-4.0.12/src/js/select2/selection/multiple.js rename to netbox/project-static/select2-4.0.13/src/js/select2/selection/multiple.js index 17afa4e40..cfd6029c9 100644 --- a/netbox/project-static/select2-4.0.12/src/js/select2/selection/multiple.js +++ b/netbox/project-static/select2-4.0.13/src/js/select2/selection/multiple.js @@ -37,7 +37,7 @@ define([ '.select2-selection__choice__remove', function (evt) { // Ignore the event if it is disabled - if (self.options.get('disabled')) { + if (self.isDisabled()) { return; } diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/selection/placeholder.js b/netbox/project-static/select2-4.0.13/src/js/select2/selection/placeholder.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/selection/placeholder.js rename to netbox/project-static/select2-4.0.13/src/js/select2/selection/placeholder.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/selection/search.js b/netbox/project-static/select2-4.0.13/src/js/select2/selection/search.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/selection/search.js rename to netbox/project-static/select2-4.0.13/src/js/select2/selection/search.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/selection/single.js b/netbox/project-static/select2-4.0.13/src/js/select2/selection/single.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/selection/single.js rename to netbox/project-static/select2-4.0.13/src/js/select2/selection/single.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/selection/stopPropagation.js b/netbox/project-static/select2-4.0.13/src/js/select2/selection/stopPropagation.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/selection/stopPropagation.js rename to netbox/project-static/select2-4.0.13/src/js/select2/selection/stopPropagation.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/translation.js b/netbox/project-static/select2-4.0.13/src/js/select2/translation.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/translation.js rename to netbox/project-static/select2-4.0.13/src/js/select2/translation.js diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/utils.js b/netbox/project-static/select2-4.0.13/src/js/select2/utils.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/select2/utils.js rename to netbox/project-static/select2-4.0.13/src/js/select2/utils.js diff --git a/netbox/project-static/select2-4.0.12/src/js/wrapper.end.js b/netbox/project-static/select2-4.0.13/src/js/wrapper.end.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/wrapper.end.js rename to netbox/project-static/select2-4.0.13/src/js/wrapper.end.js diff --git a/netbox/project-static/select2-4.0.12/src/js/wrapper.start.js b/netbox/project-static/select2-4.0.13/src/js/wrapper.start.js similarity index 100% rename from netbox/project-static/select2-4.0.12/src/js/wrapper.start.js rename to netbox/project-static/select2-4.0.13/src/js/wrapper.start.js diff --git a/netbox/project-static/select2-4.0.12/src/scss/_dropdown.scss b/netbox/project-static/select2-4.0.13/src/scss/_dropdown.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/src/scss/_dropdown.scss rename to netbox/project-static/select2-4.0.13/src/scss/_dropdown.scss diff --git a/netbox/project-static/select2-4.0.12/src/scss/_multiple.scss b/netbox/project-static/select2-4.0.13/src/scss/_multiple.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/src/scss/_multiple.scss rename to netbox/project-static/select2-4.0.13/src/scss/_multiple.scss diff --git a/netbox/project-static/select2-4.0.12/src/scss/_single.scss b/netbox/project-static/select2-4.0.13/src/scss/_single.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/src/scss/_single.scss rename to netbox/project-static/select2-4.0.13/src/scss/_single.scss diff --git a/netbox/project-static/select2-4.0.12/src/scss/core.scss b/netbox/project-static/select2-4.0.13/src/scss/core.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/src/scss/core.scss rename to netbox/project-static/select2-4.0.13/src/scss/core.scss diff --git a/netbox/project-static/select2-4.0.12/src/scss/mixins/_gradients.scss b/netbox/project-static/select2-4.0.13/src/scss/mixins/_gradients.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/src/scss/mixins/_gradients.scss rename to netbox/project-static/select2-4.0.13/src/scss/mixins/_gradients.scss diff --git a/netbox/project-static/select2-4.0.12/src/scss/theme/classic/_defaults.scss b/netbox/project-static/select2-4.0.13/src/scss/theme/classic/_defaults.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/src/scss/theme/classic/_defaults.scss rename to netbox/project-static/select2-4.0.13/src/scss/theme/classic/_defaults.scss diff --git a/netbox/project-static/select2-4.0.12/src/scss/theme/classic/_multiple.scss b/netbox/project-static/select2-4.0.13/src/scss/theme/classic/_multiple.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/src/scss/theme/classic/_multiple.scss rename to netbox/project-static/select2-4.0.13/src/scss/theme/classic/_multiple.scss diff --git a/netbox/project-static/select2-4.0.12/src/scss/theme/classic/_single.scss b/netbox/project-static/select2-4.0.13/src/scss/theme/classic/_single.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/src/scss/theme/classic/_single.scss rename to netbox/project-static/select2-4.0.13/src/scss/theme/classic/_single.scss diff --git a/netbox/project-static/select2-4.0.12/src/scss/theme/classic/layout.scss b/netbox/project-static/select2-4.0.13/src/scss/theme/classic/layout.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/src/scss/theme/classic/layout.scss rename to netbox/project-static/select2-4.0.13/src/scss/theme/classic/layout.scss diff --git a/netbox/project-static/select2-4.0.12/src/scss/theme/default/_multiple.scss b/netbox/project-static/select2-4.0.13/src/scss/theme/default/_multiple.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/src/scss/theme/default/_multiple.scss rename to netbox/project-static/select2-4.0.13/src/scss/theme/default/_multiple.scss diff --git a/netbox/project-static/select2-4.0.12/src/scss/theme/default/_single.scss b/netbox/project-static/select2-4.0.13/src/scss/theme/default/_single.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/src/scss/theme/default/_single.scss rename to netbox/project-static/select2-4.0.13/src/scss/theme/default/_single.scss diff --git a/netbox/project-static/select2-4.0.12/src/scss/theme/default/layout.scss b/netbox/project-static/select2-4.0.13/src/scss/theme/default/layout.scss similarity index 100% rename from netbox/project-static/select2-4.0.12/src/scss/theme/default/layout.scss rename to netbox/project-static/select2-4.0.13/src/scss/theme/default/layout.scss diff --git a/netbox/project-static/select2-4.0.12/tests/a11y/selection-tests.js b/netbox/project-static/select2-4.0.13/tests/a11y/selection-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/a11y/selection-tests.js rename to netbox/project-static/select2-4.0.13/tests/a11y/selection-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/data/array-tests.js b/netbox/project-static/select2-4.0.13/tests/data/array-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/data/array-tests.js rename to netbox/project-static/select2-4.0.13/tests/data/array-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/data/base-tests.js b/netbox/project-static/select2-4.0.13/tests/data/base-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/data/base-tests.js rename to netbox/project-static/select2-4.0.13/tests/data/base-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/data/inputData-tests.js b/netbox/project-static/select2-4.0.13/tests/data/inputData-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/data/inputData-tests.js rename to netbox/project-static/select2-4.0.13/tests/data/inputData-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/data/maximumInputLength-tests.js b/netbox/project-static/select2-4.0.13/tests/data/maximumInputLength-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/data/maximumInputLength-tests.js rename to netbox/project-static/select2-4.0.13/tests/data/maximumInputLength-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/data/maximumSelectionLength-tests.js b/netbox/project-static/select2-4.0.13/tests/data/maximumSelectionLength-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/data/maximumSelectionLength-tests.js rename to netbox/project-static/select2-4.0.13/tests/data/maximumSelectionLength-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/data/minimumInputLength-tests.js b/netbox/project-static/select2-4.0.13/tests/data/minimumInputLength-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/data/minimumInputLength-tests.js rename to netbox/project-static/select2-4.0.13/tests/data/minimumInputLength-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/data/select-tests.js b/netbox/project-static/select2-4.0.13/tests/data/select-tests.js similarity index 90% rename from netbox/project-static/select2-4.0.12/tests/data/select-tests.js rename to netbox/project-static/select2-4.0.13/tests/data/select-tests.js index b59c6d4be..544a51e1e 100644 --- a/netbox/project-static/select2-4.0.12/tests/data/select-tests.js +++ b/netbox/project-static/select2-4.0.13/tests/data/select-tests.js @@ -167,12 +167,14 @@ test('duplicates - single - same id on select triggers change', var data = new SelectData($select, data); var second = $('#qunit-fixture .duplicates option')[2]; - var changeTriggered = false; + var changeTriggered = false, inputTriggered = false; assert.equal($select.val(), 'one'); $select.on('change', function () { - changeTriggered = true; + changeTriggered = inputTriggered; + }).on('input', function() { + inputTriggered = true; }); data.select({ @@ -187,9 +189,14 @@ test('duplicates - single - same id on select triggers change', 'The value never changed' ); + assert.ok( + inputTriggered, + 'The input event should be triggered' + ); + assert.ok( changeTriggered, - 'The change event should be triggered' + 'The change event should be triggered after the input event' ); assert.ok( @@ -205,12 +212,14 @@ test('duplicates - single - different id on select triggers change', var data = new SelectData($select, data); var second = $('#qunit-fixture .duplicates option')[2]; - var changeTriggered = false; + var changeTriggered = false, inputTriggered = false; $select.val('two'); $select.on('change', function () { - changeTriggered = true; + changeTriggered = inputTriggered; + }).on('input', function() { + inputTriggered = true; }); data.select({ @@ -225,9 +234,14 @@ test('duplicates - single - different id on select triggers change', 'The value changed to the duplicate id' ); + assert.ok( + inputTriggered, + 'The input event should be triggered' + ); + assert.ok( changeTriggered, - 'The change event should be triggered' + 'The change event should be triggered after the input event' ); assert.ok( @@ -243,12 +257,14 @@ function (assert) { var data = new SelectData($select, data); var second = $('#qunit-fixture .duplicates-multi option')[2]; - var changeTriggered = false; + var changeTriggered = false, inputTriggered = false; $select.val(['one']); $select.on('change', function () { - changeTriggered = true; + changeTriggered = inputTriggered; + }).on('input', function() { + inputTriggered = true; }); data.select({ @@ -263,9 +279,14 @@ function (assert) { 'The value now has duplicates' ); + assert.ok( + inputTriggered, + 'The input event should be triggered' + ); + assert.ok( changeTriggered, - 'The change event should be triggered' + 'The change event should be triggered after the input event' ); assert.ok( @@ -281,12 +302,14 @@ function (assert) { var data = new SelectData($select, data); var second = $('#qunit-fixture .duplicates-multi option')[2]; - var changeTriggered = false; + var changeTriggered = false, inputTriggered = false; $select.val(['two']); $select.on('change', function () { - changeTriggered = true; + changeTriggered = inputTriggered; + }).on('input', function() { + inputTriggered = true; }); data.select({ @@ -301,9 +324,14 @@ function (assert) { 'The value has the new id' ); + assert.ok( + inputTriggered, + 'The input event should be triggered' + ); + assert.ok( changeTriggered, - 'The change event should be triggered' + 'The change event should be triggered after the input event' ); assert.ok( diff --git a/netbox/project-static/select2-4.0.12/tests/data/tags-tests.js b/netbox/project-static/select2-4.0.13/tests/data/tags-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/data/tags-tests.js rename to netbox/project-static/select2-4.0.13/tests/data/tags-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/data/tokenizer-tests.js b/netbox/project-static/select2-4.0.13/tests/data/tokenizer-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/data/tokenizer-tests.js rename to netbox/project-static/select2-4.0.13/tests/data/tokenizer-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/dropdown/dropdownCss-tests.js b/netbox/project-static/select2-4.0.13/tests/dropdown/dropdownCss-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/dropdown/dropdownCss-tests.js rename to netbox/project-static/select2-4.0.13/tests/dropdown/dropdownCss-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/dropdown/dropdownParent-tests.js b/netbox/project-static/select2-4.0.13/tests/dropdown/dropdownParent-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/dropdown/dropdownParent-tests.js rename to netbox/project-static/select2-4.0.13/tests/dropdown/dropdownParent-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/dropdown/positioning-tests.js b/netbox/project-static/select2-4.0.13/tests/dropdown/positioning-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/dropdown/positioning-tests.js rename to netbox/project-static/select2-4.0.13/tests/dropdown/positioning-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/dropdown/search-a11y-tests.js b/netbox/project-static/select2-4.0.13/tests/dropdown/search-a11y-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/dropdown/search-a11y-tests.js rename to netbox/project-static/select2-4.0.13/tests/dropdown/search-a11y-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/dropdown/selectOnClose-tests.js b/netbox/project-static/select2-4.0.13/tests/dropdown/selectOnClose-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/dropdown/selectOnClose-tests.js rename to netbox/project-static/select2-4.0.13/tests/dropdown/selectOnClose-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/dropdown/stopPropagation-tests.js b/netbox/project-static/select2-4.0.13/tests/dropdown/stopPropagation-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/dropdown/stopPropagation-tests.js rename to netbox/project-static/select2-4.0.13/tests/dropdown/stopPropagation-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/helpers.js b/netbox/project-static/select2-4.0.13/tests/helpers.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/helpers.js rename to netbox/project-static/select2-4.0.13/tests/helpers.js diff --git a/netbox/project-static/select2-4.0.12/tests/integration-jq1.html b/netbox/project-static/select2-4.0.13/tests/integration-jq1.html similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/integration-jq1.html rename to netbox/project-static/select2-4.0.13/tests/integration-jq1.html diff --git a/netbox/project-static/select2-4.0.12/tests/integration-jq2.html b/netbox/project-static/select2-4.0.13/tests/integration-jq2.html similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/integration-jq2.html rename to netbox/project-static/select2-4.0.13/tests/integration-jq2.html diff --git a/netbox/project-static/select2-4.0.12/tests/integration-jq3.html b/netbox/project-static/select2-4.0.13/tests/integration-jq3.html similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/integration-jq3.html rename to netbox/project-static/select2-4.0.13/tests/integration-jq3.html diff --git a/netbox/project-static/select2-4.0.12/tests/integration/dom-changes.js b/netbox/project-static/select2-4.0.13/tests/integration/dom-changes.js similarity index 87% rename from netbox/project-static/select2-4.0.12/tests/integration/dom-changes.js rename to netbox/project-static/select2-4.0.13/tests/integration/dom-changes.js index 65f3fb912..0ce7a4ec5 100644 --- a/netbox/project-static/select2-4.0.12/tests/integration/dom-changes.js +++ b/netbox/project-static/select2-4.0.13/tests/integration/dom-changes.js @@ -1,3 +1,4 @@ +/*jshint browser: true */ module('DOM integration'); test('adding a new unselected option changes nothing', function (assert) { @@ -286,3 +287,46 @@ test('searching tags does not loose focus', function (assert) { select.selection.trigger('query', {term: 'f'}); select.selection.trigger('query', {term: 'ff'}); }); + + +test('adding multiple options calls selection:update once', function (assert) { + assert.expect(1); + + var asyncDone = assert.async(); + + var $ = require('jquery'); + var Select2 = require('select2/core'); + + var content = ''; + + var $select = $(content); + + $('#qunit-fixture').append($select); + + var select = new Select2($select); + + var eventCalls = 0; + + select.on('selection:update', function () { + eventCalls++; + }); + + $select.html(options); + + setTimeout(function () { + assert.equal( + eventCalls, + 1, + 'selection:update was called more than once' + ); + asyncDone(); + }, 0); +}); diff --git a/netbox/project-static/select2-4.0.12/tests/integration/jquery-calls.js b/netbox/project-static/select2-4.0.13/tests/integration/jquery-calls.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/integration/jquery-calls.js rename to netbox/project-static/select2-4.0.13/tests/integration/jquery-calls.js diff --git a/netbox/project-static/select2-4.0.12/tests/integration/select2-methods.js b/netbox/project-static/select2-4.0.13/tests/integration/select2-methods.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/integration/select2-methods.js rename to netbox/project-static/select2-4.0.13/tests/integration/select2-methods.js diff --git a/netbox/project-static/select2-4.0.12/tests/options/ajax-tests.js b/netbox/project-static/select2-4.0.13/tests/options/ajax-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/options/ajax-tests.js rename to netbox/project-static/select2-4.0.13/tests/options/ajax-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/options/data-tests.js b/netbox/project-static/select2-4.0.13/tests/options/data-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/options/data-tests.js rename to netbox/project-static/select2-4.0.13/tests/options/data-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/options/deprecated-tests.js b/netbox/project-static/select2-4.0.13/tests/options/deprecated-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/options/deprecated-tests.js rename to netbox/project-static/select2-4.0.13/tests/options/deprecated-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/options/translation-tests.js b/netbox/project-static/select2-4.0.13/tests/options/translation-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/options/translation-tests.js rename to netbox/project-static/select2-4.0.13/tests/options/translation-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/options/width-tests.js b/netbox/project-static/select2-4.0.13/tests/options/width-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/options/width-tests.js rename to netbox/project-static/select2-4.0.13/tests/options/width-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/results/a11y-tests.js b/netbox/project-static/select2-4.0.13/tests/results/a11y-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/results/a11y-tests.js rename to netbox/project-static/select2-4.0.13/tests/results/a11y-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/results/focusing-tests.js b/netbox/project-static/select2-4.0.13/tests/results/focusing-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/results/focusing-tests.js rename to netbox/project-static/select2-4.0.13/tests/results/focusing-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/results/infiniteScroll-tests.js b/netbox/project-static/select2-4.0.13/tests/results/infiniteScroll-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/results/infiniteScroll-tests.js rename to netbox/project-static/select2-4.0.13/tests/results/infiniteScroll-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/results/option-tests.js b/netbox/project-static/select2-4.0.13/tests/results/option-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/results/option-tests.js rename to netbox/project-static/select2-4.0.13/tests/results/option-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/selection/allowClear-tests.js b/netbox/project-static/select2-4.0.13/tests/selection/allowClear-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/selection/allowClear-tests.js rename to netbox/project-static/select2-4.0.13/tests/selection/allowClear-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/selection/containerCss-tests.js b/netbox/project-static/select2-4.0.13/tests/selection/containerCss-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/selection/containerCss-tests.js rename to netbox/project-static/select2-4.0.13/tests/selection/containerCss-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/selection/focusing-tests.js b/netbox/project-static/select2-4.0.13/tests/selection/focusing-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/selection/focusing-tests.js rename to netbox/project-static/select2-4.0.13/tests/selection/focusing-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/selection/multiple-tests.js b/netbox/project-static/select2-4.0.13/tests/selection/multiple-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/selection/multiple-tests.js rename to netbox/project-static/select2-4.0.13/tests/selection/multiple-tests.js diff --git a/netbox/project-static/select2-4.0.13/tests/selection/openOnKeyDown-tests.js b/netbox/project-static/select2-4.0.13/tests/selection/openOnKeyDown-tests.js new file mode 100644 index 000000000..0da838e2c --- /dev/null +++ b/netbox/project-static/select2-4.0.13/tests/selection/openOnKeyDown-tests.js @@ -0,0 +1,188 @@ +module('Selection containers - Open On Key Down'); + +var KEYS = require('select2/keys'); +var $ = require('jquery'); + +/** + * Build a keydown event with the given key code and extra options. + * + * @param {Number} keyCode the keyboard code to be used for the 'which' + * attribute of the keydown event. + * @param {Object} eventProps extra properties to build the keydown event. + * + * @return {jQuery.Event} a 'keydown' type event. + */ +function buildKeyDownEvent (keyCode, eventProps) { + return $.Event('keydown', $.extend({}, { which: keyCode }, eventProps)); +} + +/** + * Wrapper function providing a select2 element with a given enabled/disabled + * state that will get a given keydown event triggered on it. Provide an + * assertion callback function to test the results of the triggered event. + * + * @param {Boolean} isEnabled the enabled state of the desired select2 + * element. + * @param {String} testName name for the test. + * @param {Number} keyCode used to set the 'which' attribute of the + * keydown event. + * @param {Object} eventProps attributes to be used to build the keydown + * event. + * @param {Function} fn assertion callback to perform checks on the + * result of triggering the event, receives the + * 'assert' variable for the test and the select2 + * instance behind the built ' + + '' + + '' + + '' + ); + $('#qunit-fixture').append($element); + $element.select2({ disabled: !isEnabled }); + + var select2 = $element.data('select2'); + var $selection = select2.$selection; + + assert.notOk(select2.isOpen(), 'The instance should not be open'); + assert.equal(select2.isEnabled(), isEnabled); + + var event = buildKeyDownEvent(keyCode, eventProps); + assert.ok(event.which, 'The event\'s key code (.which) should be set'); + + $selection.trigger(event); + + fn(assert, select2); + }); +} + +/** + * Test the given keydown event on an enabled element. See #testAbled for + * params. + */ +function testEnabled (testName, keyCode, eventProps, fn) { + testAbled(true, testName, keyCode, eventProps, fn); +} + +/** + * Test the given keydown event on a disabled element. See #testAbled for + * params. + */ +function testDisabled (testName, keyCode, eventProps, fn) { + testAbled(false, testName, keyCode, eventProps, fn); +} + +/** + * Assertion function used by the above test* wrappers. Asserts that the given + * select2 instance is open. + * + * @param {Assert} assert + * @param {Select2} select + * @return {null} + */ +function assertOpened (assert, select2) { + assert.ok(select2.isOpen(), 'The element should be open'); +} + +/** + * Assertion function used by the above test* wrappers. Asserts that the given + * select2 instance is not open. + * + * @param {Assert} assert + * @param {Select2} select + * @return {null} + */ +function assertNotOpened (assert, select2) { + assert.notOk(select2.isOpen(), 'The element should not be open'); +} + +/** + * ENTER, SPACE, and ALT+DOWN should all open an enabled select2 element. + */ +testEnabled( + 'enabled element will open on ENTER', + KEYS.ENTER, {}, + assertOpened +); +testEnabled( + 'enabled element will open on SPACE', + KEYS.SPACE, {}, + assertOpened +); +testEnabled( + 'enabled element will open on ALT+DOWN', + KEYS.DOWN, { altKey: true }, + assertOpened +); + +/** + * Some other keys triggered on an enabled select2 element should not open it. + */ +testEnabled( + 'enabled element will not open on UP', + KEYS.UP, {}, + assertNotOpened +); +testEnabled( + 'enabled element will not open on DOWN', + KEYS.UP, {}, + assertNotOpened +); +testEnabled( + 'enabled element will not open on LEFT', + KEYS.UP, {}, + assertNotOpened +); +testEnabled( + 'enabled element will not open on RIGHT', + KEYS.UP, {}, + assertNotOpened +); + +/* + * The keys that will open an enabled select2 element should not open a disabled + * one. + */ +testDisabled( + 'disabled element will not open on ENTER', + KEYS.ENTER, {}, + assertNotOpened +); +testDisabled( + 'disabled element will not open on SPACE', + KEYS.SPACE, {}, + assertNotOpened +); +testDisabled( + 'disabled element will not open on ALT+DOWN', + KEYS.DOWN, { altKey: true }, + assertNotOpened +); + +/** + * Other keys should continue to not open a disabled select2 element. + */ +testDisabled( + 'disabled element will not open on UP', + KEYS.UP, {}, + assertNotOpened +); +testDisabled( + 'disabled element will not open on DOWN', + KEYS.UP, {}, + assertNotOpened +); +testDisabled( + 'disabled element will not open on LEFT', + KEYS.UP, {}, + assertNotOpened +); +testDisabled( + 'disabled element will not open on RIGHT', + KEYS.UP, {}, + assertNotOpened +); diff --git a/netbox/project-static/select2-4.0.12/tests/selection/placeholder-tests.js b/netbox/project-static/select2-4.0.13/tests/selection/placeholder-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/selection/placeholder-tests.js rename to netbox/project-static/select2-4.0.13/tests/selection/placeholder-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/selection/search-a11y-tests.js b/netbox/project-static/select2-4.0.13/tests/selection/search-a11y-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/selection/search-a11y-tests.js rename to netbox/project-static/select2-4.0.13/tests/selection/search-a11y-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/selection/search-placeholder-tests.js b/netbox/project-static/select2-4.0.13/tests/selection/search-placeholder-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/selection/search-placeholder-tests.js rename to netbox/project-static/select2-4.0.13/tests/selection/search-placeholder-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/selection/search-tests.js b/netbox/project-static/select2-4.0.13/tests/selection/search-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/selection/search-tests.js rename to netbox/project-static/select2-4.0.13/tests/selection/search-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/selection/single-tests.js b/netbox/project-static/select2-4.0.13/tests/selection/single-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/selection/single-tests.js rename to netbox/project-static/select2-4.0.13/tests/selection/single-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/selection/stopPropagation-tests.js b/netbox/project-static/select2-4.0.13/tests/selection/stopPropagation-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/selection/stopPropagation-tests.js rename to netbox/project-static/select2-4.0.13/tests/selection/stopPropagation-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/unit-jq1.html b/netbox/project-static/select2-4.0.13/tests/unit-jq1.html similarity index 98% rename from netbox/project-static/select2-4.0.12/tests/unit-jq1.html rename to netbox/project-static/select2-4.0.13/tests/unit-jq1.html index b5ec34612..f1dac344f 100644 --- a/netbox/project-static/select2-4.0.12/tests/unit-jq1.html +++ b/netbox/project-static/select2-4.0.13/tests/unit-jq1.html @@ -97,6 +97,7 @@ + diff --git a/netbox/project-static/select2-4.0.12/tests/unit-jq2.html b/netbox/project-static/select2-4.0.13/tests/unit-jq2.html similarity index 98% rename from netbox/project-static/select2-4.0.12/tests/unit-jq2.html rename to netbox/project-static/select2-4.0.13/tests/unit-jq2.html index 7eca50575..9d4a99b15 100644 --- a/netbox/project-static/select2-4.0.12/tests/unit-jq2.html +++ b/netbox/project-static/select2-4.0.13/tests/unit-jq2.html @@ -97,6 +97,7 @@ + diff --git a/netbox/project-static/select2-4.0.12/tests/unit-jq3.html b/netbox/project-static/select2-4.0.13/tests/unit-jq3.html similarity index 98% rename from netbox/project-static/select2-4.0.12/tests/unit-jq3.html rename to netbox/project-static/select2-4.0.13/tests/unit-jq3.html index a34f771c9..9e062678a 100644 --- a/netbox/project-static/select2-4.0.12/tests/unit-jq3.html +++ b/netbox/project-static/select2-4.0.13/tests/unit-jq3.html @@ -97,6 +97,7 @@ + diff --git a/netbox/project-static/select2-4.0.12/tests/utils/data-tests.js b/netbox/project-static/select2-4.0.13/tests/utils/data-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/utils/data-tests.js rename to netbox/project-static/select2-4.0.13/tests/utils/data-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/utils/decorator-tests.js b/netbox/project-static/select2-4.0.13/tests/utils/decorator-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/utils/decorator-tests.js rename to netbox/project-static/select2-4.0.13/tests/utils/decorator-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/utils/escapeMarkup-tests.js b/netbox/project-static/select2-4.0.13/tests/utils/escapeMarkup-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/utils/escapeMarkup-tests.js rename to netbox/project-static/select2-4.0.13/tests/utils/escapeMarkup-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/vendor/jquery-1.7.2.js b/netbox/project-static/select2-4.0.13/tests/vendor/jquery-1.7.2.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/vendor/jquery-1.7.2.js rename to netbox/project-static/select2-4.0.13/tests/vendor/jquery-1.7.2.js diff --git a/netbox/project-static/select2-4.0.12/tests/vendor/jquery-2.2.4.js b/netbox/project-static/select2-4.0.13/tests/vendor/jquery-2.2.4.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/vendor/jquery-2.2.4.js rename to netbox/project-static/select2-4.0.13/tests/vendor/jquery-2.2.4.js diff --git a/netbox/project-static/select2-4.0.12/tests/vendor/jquery-3.4.1.js b/netbox/project-static/select2-4.0.13/tests/vendor/jquery-3.4.1.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/vendor/jquery-3.4.1.js rename to netbox/project-static/select2-4.0.13/tests/vendor/jquery-3.4.1.js diff --git a/netbox/project-static/select2-4.0.12/tests/vendor/qunit-1.23.1.css b/netbox/project-static/select2-4.0.13/tests/vendor/qunit-1.23.1.css similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/vendor/qunit-1.23.1.css rename to netbox/project-static/select2-4.0.13/tests/vendor/qunit-1.23.1.css diff --git a/netbox/project-static/select2-4.0.12/tests/vendor/qunit-1.23.1.js b/netbox/project-static/select2-4.0.13/tests/vendor/qunit-1.23.1.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/vendor/qunit-1.23.1.js rename to netbox/project-static/select2-4.0.13/tests/vendor/qunit-1.23.1.js diff --git a/netbox/secrets/admin.py b/netbox/secrets/admin.py index 94cd1c7fa..ceb6e0426 100644 --- a/netbox/secrets/admin.py +++ b/netbox/secrets/admin.py @@ -1,4 +1,5 @@ from django.contrib import admin, messages +from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME from django.shortcuts import redirect, render from .forms import ActivateUserKeyForm @@ -23,7 +24,7 @@ class UserKeyAdmin(admin.ModelAdmin): actions = super().get_actions(request) if 'delete_selected' in actions: del actions['delete_selected'] - if not request.user.has_perm('secrets.activate_userkey'): + if not request.user.has_perm('secrets.change_userkey'): del actions['activate_selected'] return actions @@ -50,7 +51,9 @@ class UserKeyAdmin(admin.ModelAdmin): request, "Invalid private key provided. Unable to retrieve master key.", extra_tags='error' ) else: - form = ActivateUserKeyForm(initial={'_selected_action': request.POST.getlist(admin.ACTION_CHECKBOX_NAME)}) + form = ActivateUserKeyForm( + initial={'_selected_action': request.POST.getlist(ACTION_CHECKBOX_NAME)} + ) return render(request, 'activate_keys.html', { 'form': form, diff --git a/netbox/secrets/api/nested_serializers.py b/netbox/secrets/api/nested_serializers.py index 7aa8087da..aaec27c1f 100644 --- a/netbox/secrets/api/nested_serializers.py +++ b/netbox/secrets/api/nested_serializers.py @@ -1,13 +1,22 @@ from rest_framework import serializers -from secrets.models import SecretRole -from utilities.api import WritableNestedSerializer +from netbox.api import WritableNestedSerializer +from secrets.models import Secret, SecretRole __all__ = [ - 'NestedSecretRoleSerializer' + 'NestedSecretRoleSerializer', + 'NestedSecretSerializer', ] +class NestedSecretSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secret-detail') + + class Meta: + model = Secret + fields = ['id', 'url', 'name'] + + class NestedSecretRoleSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secretrole-detail') secret_count = serializers.IntegerField(read_only=True) diff --git a/netbox/secrets/api/serializers.py b/netbox/secrets/api/serializers.py index 0b73f0002..b08b87bc5 100644 --- a/netbox/secrets/api/serializers.py +++ b/netbox/secrets/api/serializers.py @@ -1,10 +1,13 @@ +from django.contrib.contenttypes.models import ContentType +from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers -from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField -from dcim.api.nested_serializers import NestedDeviceSerializer from extras.api.customfields import CustomFieldModelSerializer +from extras.api.serializers import TaggedObjectSerializer +from secrets.constants import SECRET_ASSIGNMENT_MODELS from secrets.models import Secret, SecretRole -from utilities.api import ValidatedModelSerializer +from netbox.api import ContentTypeField, ValidatedModelSerializer +from utilities.api import get_serializer_for_model from .nested_serializers import * @@ -13,26 +16,37 @@ from .nested_serializers import * # class SecretRoleSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secretrole-detail') secret_count = serializers.IntegerField(read_only=True) class Meta: model = SecretRole - fields = ['id', 'name', 'slug', 'description', 'secret_count'] + fields = ['id', 'url', 'name', 'slug', 'description', 'secret_count'] -class SecretSerializer(TaggitSerializer, CustomFieldModelSerializer): - device = NestedDeviceSerializer() +class SecretSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secret-detail') + assigned_object_type = ContentTypeField( + queryset=ContentType.objects.filter(SECRET_ASSIGNMENT_MODELS) + ) + assigned_object = serializers.SerializerMethodField(read_only=True) role = NestedSecretRoleSerializer() plaintext = serializers.CharField() - tags = TagListSerializerField(required=False) class Meta: model = Secret fields = [ - 'id', 'device', 'role', 'name', 'plaintext', 'hash', 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'role', 'name', 'plaintext', + 'hash', 'tags', 'custom_fields', 'created', 'last_updated', ] validators = [] + @swagger_serializer_method(serializer_or_field=serializers.DictField) + def get_assigned_object(self, obj): + serializer = get_serializer_for_model(obj.assigned_object, prefix='Nested') + context = {'request': self.context['request']} + return serializer(obj.assigned_object, context=context).data + def validate(self, data): # Encrypt plaintext data using the master key provided from the view context diff --git a/netbox/secrets/api/urls.py b/netbox/secrets/api/urls.py index 7ae2ae9ac..4000177b2 100644 --- a/netbox/secrets/api/urls.py +++ b/netbox/secrets/api/urls.py @@ -1,18 +1,9 @@ -from rest_framework import routers - +from netbox.api import OrderedDefaultRouter from . import views -class SecretsRootView(routers.APIRootView): - """ - Secrets API root view - """ - def get_view_name(self): - return 'Secrets' - - -router = routers.DefaultRouter() -router.APIRootView = SecretsRootView +router = OrderedDefaultRouter() +router.APIRootView = views.SecretsRootView # Secrets router.register('secret-roles', views.SecretRoleViewSet) diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index 1795e6c0a..8c959f90d 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -1,17 +1,18 @@ import base64 from Crypto.PublicKey import RSA -from django.db.models import Count from django.http import HttpResponseBadRequest from rest_framework.exceptions import ValidationError from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from rest_framework.routers import APIRootView from rest_framework.viewsets import ViewSet +from netbox.api.views import ModelViewSet from secrets import filters from secrets.exceptions import InvalidKey from secrets.models import Secret, SecretRole, SessionKey, UserKey -from utilities.api import ModelViewSet +from utilities.utils import count_related from . import serializers ERR_USERKEY_MISSING = "No UserKey found for the current user." @@ -20,16 +21,23 @@ ERR_PRIVKEY_MISSING = "Private key was not provided." ERR_PRIVKEY_INVALID = "Invalid private key." +class SecretsRootView(APIRootView): + """ + Secrets API root view + """ + def get_view_name(self): + return 'Secrets' + + # # Secret Roles # class SecretRoleViewSet(ModelViewSet): queryset = SecretRole.objects.annotate( - secret_count=Count('secrets') + secret_count=count_related(Secret, 'role') ) serializer_class = serializers.SecretRoleSerializer - permission_classes = [IsAuthenticated] filterset_class = filters.SecretRoleFilterSet @@ -38,9 +46,7 @@ class SecretRoleViewSet(ModelViewSet): # class SecretViewSet(ModelViewSet): - queryset = Secret.objects.prefetch_related( - 'device__primary_ip4', 'device__primary_ip6', 'role', 'role__users', 'role__groups', 'tags', - ) + queryset = Secret.objects.prefetch_related('role', 'tags') serializer_class = serializers.SecretSerializer filterset_class = filters.SecretFilterSet @@ -85,8 +91,8 @@ class SecretViewSet(ModelViewSet): secret = self.get_object() - # Attempt to decrypt the secret if the user is permitted and the master key is known - if secret.decryptable_by(request.user) and self.master_key is not None: + # Attempt to decrypt the secret if the master key is known + if self.master_key is not None: secret.decrypt(self.master_key) serializer = self.get_serializer(secret) @@ -103,9 +109,7 @@ class SecretViewSet(ModelViewSet): if self.master_key is not None: secrets = [] for secret in page: - # Enforce role permissions - if secret.decryptable_by(request.user): - secret.decrypt(self.master_key) + secret.decrypt(self.master_key) secrets.append(secret) serializer = self.get_serializer(secrets, many=True) else: diff --git a/netbox/secrets/constants.py b/netbox/secrets/constants.py index a1c3cb3da..16803820e 100644 --- a/netbox/secrets/constants.py +++ b/netbox/secrets/constants.py @@ -1,5 +1,13 @@ +from django.db.models import Q + + # # Secrets # +SECRET_ASSIGNMENT_MODELS = Q( + Q(app_label='dcim', model='device') | + Q(app_label='virtualization', model='virtualmachine') +) + SECRET_PLAINTEXT_MAX_LENGTH = 65535 diff --git a/netbox/secrets/decorators.py b/netbox/secrets/decorators.py deleted file mode 100644 index e2f44ac90..000000000 --- a/netbox/secrets/decorators.py +++ /dev/null @@ -1,24 +0,0 @@ -from django.contrib import messages -from django.shortcuts import redirect - -from .models import UserKey - - -def userkey_required(): - """ - Decorator for views which require that the user has an active UserKey (typically for encryption/decryption of - Secrets). - """ - def _decorator(view): - def wrapped_view(request, *args, **kwargs): - try: - uk = UserKey.objects.get(user=request.user) - except UserKey.DoesNotExist: - messages.warning(request, "This operation requires an active user key, but you don't have one.") - return redirect('user:userkey') - if not uk.is_active(): - messages.warning(request, "This operation is not available. Your user key has not been activated.") - return redirect('user:userkey') - return view(request, *args, **kwargs) - return wrapped_view - return _decorator diff --git a/netbox/secrets/filters.py b/netbox/secrets/filters.py index 78f25952a..0cf4c2045 100644 --- a/netbox/secrets/filters.py +++ b/netbox/secrets/filters.py @@ -2,8 +2,9 @@ import django_filters from django.db.models import Q from dcim.models import Device -from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet +from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet from utilities.filters import BaseFilterSet, NameSlugSearchFilterSet, TagFilter +from virtualization.models import VirtualMachine from .models import Secret, SecretRole @@ -20,7 +21,7 @@ class SecretRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet): fields = ['id', 'name', 'slug'] -class SecretFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): +class SecretFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -35,16 +36,28 @@ class SecretFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterS to_field_name='slug', label='Role (slug)', ) - device_id = django_filters.ModelMultipleChoiceFilter( - queryset=Device.objects.all(), - label='Device (ID)', - ) device = django_filters.ModelMultipleChoiceFilter( field_name='device__name', queryset=Device.objects.all(), to_field_name='name', label='Device (name)', ) + device_id = django_filters.ModelMultipleChoiceFilter( + field_name='device', + queryset=Device.objects.all(), + label='Device (ID)', + ) + virtual_machine = django_filters.ModelMultipleChoiceFilter( + field_name='virtual_machine__name', + queryset=VirtualMachine.objects.all(), + to_field_name='name', + label='Virtual machine (name)', + ) + virtual_machine_id = django_filters.ModelMultipleChoiceFilter( + field_name='virtual_machine', + queryset=VirtualMachine.objects.all(), + label='Virtual machine (ID)', + ) tag = TagFilter() class Meta: diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index 089771bd8..cdd843e2d 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -1,16 +1,18 @@ from Crypto.Cipher import PKCS1_OAEP from Crypto.PublicKey import RSA from django import forms +from django.contrib.contenttypes.models import ContentType from dcim.models import Device from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm, - TagField, ) +from extras.models import Tag from utilities.forms import ( - APISelectMultiple, BootstrapMixin, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, - DynamicModelMultipleChoiceField, SlugField, StaticSelect2Multiple, TagFilterField, + BootstrapMixin, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField, + SlugField, TagFilterField, ) +from virtualization.models import VirtualMachine from .constants import * from .models import Secret, SecretRole, UserKey @@ -46,13 +48,7 @@ class SecretRoleForm(BootstrapMixin, forms.ModelForm): class Meta: model = SecretRole - fields = [ - 'name', 'slug', 'description', 'users', 'groups', - ] - widgets = { - 'users': StaticSelect2Multiple(), - 'groups': StaticSelect2Multiple(), - } + fields = ('name', 'slug', 'description') class SecretRoleCSVForm(CSVModelForm): @@ -69,7 +65,13 @@ class SecretRoleCSVForm(CSVModelForm): class SecretForm(BootstrapMixin, CustomFieldModelForm): device = DynamicModelChoiceField( - queryset=Device.objects.all() + queryset=Device.objects.all(), + required=False, + display_field='display_name' + ) + virtual_machine = DynamicModelChoiceField( + queryset=VirtualMachine.objects.all(), + required=False ) plaintext = forms.CharField( max_length=SECRET_PLAINTEXT_MAX_LENGTH, @@ -90,17 +92,29 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm): role = DynamicModelChoiceField( queryset=SecretRole.objects.all() ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) class Meta: model = Secret fields = [ - 'device', 'role', 'name', 'plaintext', 'plaintext2', 'tags', + 'device', 'virtual_machine', 'role', 'name', 'plaintext', 'plaintext2', 'tags', ] def __init__(self, *args, **kwargs): + + # Initialize helper selectors + instance = kwargs.get('instance') + initial = kwargs.get('initial', {}).copy() + if instance: + if type(instance.assigned_object) is Device: + initial['device'] = instance.assigned_object + elif type(instance.assigned_object) is VirtualMachine: + initial['virtual_machine'] = instance.assigned_object + kwargs['initial'] = initial + super().__init__(*args, **kwargs) # A plaintext value is required when creating a new Secret @@ -108,6 +122,13 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm): self.fields['plaintext'].required = True def clean(self): + super().clean() + + if not self.cleaned_data['device'] and not self.cleaned_data['virtual_machine']: + raise forms.ValidationError("Secrets must be assigned to a device or virtual machine.") + + if self.cleaned_data['device'] and self.cleaned_data['virtual_machine']: + raise forms.ValidationError("Cannot select both a device and virtual machine for secret assignment.") # Verify that the provided plaintext values match if self.cleaned_data['plaintext'] != self.cleaned_data['plaintext2']: @@ -115,32 +136,64 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm): 'plaintext2': "The two given plaintext values do not match. Please check your input." }) + def save(self, *args, **kwargs): + # Set assigned object + self.instance.assigned_object = self.cleaned_data.get('device') or self.cleaned_data.get('virtual_machine') + + return super().save(*args, **kwargs) + class SecretCSVForm(CustomFieldModelCSVForm): - device = CSVModelChoiceField( - queryset=Device.objects.all(), - to_field_name='name', - help_text='Assigned device' - ) role = CSVModelChoiceField( queryset=SecretRole.objects.all(), to_field_name='name', help_text='Assigned role' ) + device = CSVModelChoiceField( + queryset=Device.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned device' + ) + virtual_machine = CSVModelChoiceField( + queryset=VirtualMachine.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned VM' + ) plaintext = forms.CharField( help_text='Plaintext secret data' ) class Meta: model = Secret - fields = Secret.csv_headers + fields = ['role', 'name', 'plaintext', 'device', 'virtual_machine'] help_texts = { 'name': 'Name or username', } + def clean(self): + super().clean() + + device = self.cleaned_data.get('device') + virtual_machine = self.cleaned_data.get('virtual_machine') + + # Validate device OR VM is assigned + if not device and not virtual_machine: + raise forms.ValidationError("Secret must be assigned to a device or a virtual machine") + if device and virtual_machine: + raise forms.ValidationError("Secret cannot be assigned to both a device and a virtual machine") + def save(self, *args, **kwargs): + + # Set device/VM assignment + self.instance.assigned_object = self.cleaned_data['device'] or self.cleaned_data['virtual_machine'] + s = super().save(*args, **kwargs) + + # Set plaintext on instance s.plaintext = str(self.cleaned_data['plaintext']) + return s @@ -173,10 +226,7 @@ class SecretFilterForm(BootstrapMixin, CustomFieldFilterForm): role = DynamicModelMultipleChoiceField( queryset=SecretRole.objects.all(), to_field_name='slug', - required=False, - widget=APISelectMultiple( - value_field="slug", - ) + required=False ) tag = TagFilterField(model) diff --git a/netbox/secrets/migrations/0001_initial.py b/netbox/secrets/migrations/0001_initial.py index 1281a266a..3664bae63 100644 --- a/netbox/secrets/migrations/0001_initial.py +++ b/netbox/secrets/migrations/0001_initial.py @@ -56,7 +56,6 @@ class Migration(migrations.Migration): ], options={ 'ordering': ['user__username'], - 'permissions': (('activate_userkey', 'Can activate user keys for decryption'),), }, ), migrations.AddField( diff --git a/netbox/secrets/migrations/0009_secretrole_drop_users_groups.py b/netbox/secrets/migrations/0009_secretrole_drop_users_groups.py new file mode 100644 index 000000000..e4110b505 --- /dev/null +++ b/netbox/secrets/migrations/0009_secretrole_drop_users_groups.py @@ -0,0 +1,20 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('secrets', '0008_standardize_description'), + ('users', '0009_replicate_permissions'), + ] + + operations = [ + migrations.RemoveField( + model_name='secretrole', + name='groups', + ), + migrations.RemoveField( + model_name='secretrole', + name='users', + ), + ] diff --git a/netbox/secrets/migrations/0010_custom_field_data.py b/netbox/secrets/migrations/0010_custom_field_data.py new file mode 100644 index 000000000..6d48e7cab --- /dev/null +++ b/netbox/secrets/migrations/0010_custom_field_data.py @@ -0,0 +1,17 @@ +import django.core.serializers.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('secrets', '0009_secretrole_drop_users_groups'), + ] + + operations = [ + migrations.AddField( + model_name='secret', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + ] diff --git a/netbox/secrets/migrations/0011_secret_generic_assignments.py b/netbox/secrets/migrations/0011_secret_generic_assignments.py new file mode 100644 index 000000000..02a0e0e21 --- /dev/null +++ b/netbox/secrets/migrations/0011_secret_generic_assignments.py @@ -0,0 +1,67 @@ +from django.db import migrations, models +import django.db.models.deletion + + +def device_to_generic_assignment(apps, schema_editor): + ContentType = apps.get_model('contenttypes', 'ContentType') + Device = apps.get_model('dcim', 'Device') + Secret = apps.get_model('secrets', 'Secret') + + device_ct = ContentType.objects.get_for_model(Device) + Secret.objects.update(assigned_object_type=device_ct, assigned_object_id=models.F('device_id')) + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('secrets', '0010_custom_field_data'), + ] + + operations = [ + migrations.AlterModelOptions( + name='secret', + options={'ordering': ('role', 'name', 'pk')}, + ), + + # Add assigned_object type & ID fields + migrations.AddField( + model_name='secret', + name='assigned_object_id', + field=models.PositiveIntegerField(blank=True, null=True), + preserve_default=False, + ), + migrations.AddField( + model_name='secret', + name='assigned_object_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype'), + preserve_default=False, + ), + + migrations.AlterUniqueTogether( + name='secret', + unique_together={('assigned_object_type', 'assigned_object_id', 'role', 'name')}, + ), + + # Copy device assignments and delete device ForeignKey + migrations.RunPython( + code=device_to_generic_assignment, + reverse_code=migrations.RunPython.noop + ), + migrations.RemoveField( + model_name='secret', + name='device', + ), + + # Remove blank/null from assigned_object fields + migrations.AlterField( + model_name='secret', + name='assigned_object_id', + field=models.PositiveIntegerField(), + ), + migrations.AlterField( + model_name='secret', + name='assigned_object_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype'), + ), + ] diff --git a/netbox/secrets/migrations/0012_standardize_name_length.py b/netbox/secrets/migrations/0012_standardize_name_length.py new file mode 100644 index 000000000..e41d81761 --- /dev/null +++ b/netbox/secrets/migrations/0012_standardize_name_length.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1 on 2020-10-15 19:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('secrets', '0011_secret_generic_assignments'), + ] + + operations = [ + migrations.AlterField( + model_name='secretrole', + name='name', + field=models.CharField(max_length=100, unique=True), + ), + migrations.AlterField( + model_name='secretrole', + name='slug', + field=models.SlugField(max_length=100, unique=True), + ), + ] diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 830e91096..0fa6fd04e 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -1,5 +1,4 @@ import os -import sys from Crypto.Cipher import AES from Crypto.PublicKey import RSA @@ -7,17 +6,17 @@ from Crypto.Util import strxor from django.conf import settings from django.contrib.auth.hashers import make_password, check_password from django.contrib.auth.models import Group, User -from django.contrib.contenttypes.fields import GenericRelation +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils.encoding import force_bytes from taggit.managers import TaggableManager -from dcim.models import Device -from extras.models import CustomFieldModel, TaggedItem +from extras.models import ChangeLoggedModel, CustomFieldModel, TaggedItem from extras.utils import extras_features -from utilities.models import ChangeLoggedModel +from utilities.querysets import RestrictedQuerySet from .exceptions import InvalidKey from .hashers import SecretValidationHasher from .querysets import UserKeyQuerySet @@ -64,9 +63,6 @@ class UserKey(models.Model): class Meta: ordering = ['user__username'] - permissions = ( - ('activate_userkey', "Can activate user keys for decryption"), - ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -78,7 +74,8 @@ class UserKey(models.Model): def __str__(self): return self.user.username - def clean(self, *args, **kwargs): + def clean(self): + super().clean() if self.public_key: @@ -109,8 +106,6 @@ class UserKey(models.Model): ) }) - super().clean() - def save(self, *args, **kwargs): # Check whether public_key has been modified. If so, nullify the initial master_key_cipher. @@ -243,31 +238,21 @@ class SecretRole(ChangeLoggedModel): """ A SecretRole represents an arbitrary functional classification of Secrets. For example, a user might define roles such as "Login Credentials" or "SNMP Communities." - - By default, only superusers will have access to decrypt Secrets. To allow other users to decrypt Secrets, grant them - access to the appropriate SecretRoles either individually or by group. """ name = models.CharField( - max_length=50, + max_length=100, unique=True ) slug = models.SlugField( + max_length=100, unique=True ) description = models.CharField( max_length=200, blank=True, ) - users = models.ManyToManyField( - to=User, - related_name='secretroles', - blank=True - ) - groups = models.ManyToManyField( - to=Group, - related_name='secretroles', - blank=True - ) + + objects = RestrictedQuerySet.as_manager() csv_headers = ['name', 'slug', 'description'] @@ -287,30 +272,26 @@ class SecretRole(ChangeLoggedModel): self.description, ) - def has_member(self, user): - """ - Check whether the given user has belongs to this SecretRole. Note that superusers belong to all roles. - """ - if user.is_superuser: - return True - return user in self.users.all() or user.groups.filter(pk__in=self.groups.all()).exists() - @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Secret(ChangeLoggedModel, CustomFieldModel): """ A Secret stores an AES256-encrypted copy of sensitive data, such as passwords or secret keys. An irreversible - SHA-256 hash is stored along with the ciphertext for validation upon decryption. Each Secret is assigned to a - Device; Devices may have multiple Secrets associated with them. A name can optionally be defined along with the - ciphertext; this string is stored as plain text in the database. + SHA-256 hash is stored along with the ciphertext for validation upon decryption. Each Secret is assigned to exactly + one NetBox object, and objects may have multiple Secrets associated with them. A name can optionally be defined + along with the ciphertext; this string is stored as plain text in the database. A Secret can be up to 65,535 bytes (64KB - 1B) in length. Each secret string will be padded with random data to a minimum of 64 bytes during encryption in order to protect short strings from ciphertext analysis. """ - device = models.ForeignKey( - to='dcim.Device', - on_delete=models.CASCADE, - related_name='secrets' + assigned_object_type = models.ForeignKey( + to=ContentType, + on_delete=models.PROTECT + ) + assigned_object_id = models.PositiveIntegerField() + assigned_object = GenericForeignKey( + ct_field='assigned_object_type', + fk_field='assigned_object_id' ) role = models.ForeignKey( to='secrets.SecretRole', @@ -329,43 +310,31 @@ class Secret(ChangeLoggedModel, CustomFieldModel): max_length=128, editable=False ) - custom_field_values = GenericRelation( - to='extras.CustomFieldValue', - content_type_field='obj_type', - object_id_field='obj_id' - ) - tags = TaggableManager(through=TaggedItem) + objects = RestrictedQuerySet.as_manager() + plaintext = None - csv_headers = ['device', 'role', 'name', 'plaintext'] + csv_headers = ['assigned_object_type', 'assigned_object_id', 'role', 'name', 'plaintext'] class Meta: - ordering = ['device', 'role', 'name'] - unique_together = ['device', 'role', 'name'] + ordering = ('role', 'name', 'pk') + unique_together = ('assigned_object_type', 'assigned_object_id', 'role', 'name') def __init__(self, *args, **kwargs): self.plaintext = kwargs.pop('plaintext', None) super().__init__(*args, **kwargs) def __str__(self): - try: - device = self.device - except Device.DoesNotExist: - device = None - if self.role and device and self.name: - return '{} for {} ({})'.format(self.role, self.device, self.name) - # Return role and device if no name is set - if self.role and device: - return '{} for {}'.format(self.role, self.device) - return 'Secret' + return self.name or 'Secret' def get_absolute_url(self): return reverse('secrets:secret', args=[self.pk]) def to_csv(self): return ( - self.device, + f'{self.assigned_object_type.app_label}.{self.assigned_object_type.model}', + self.assigned_object_id, self.role, self.name, self.plaintext or '', @@ -454,9 +423,3 @@ class Secret(ChangeLoggedModel, CustomFieldModel): if not self.hash: raise Exception("Hash has not been generated for this secret.") return check_password(plaintext, self.hash, preferred=SecretValidationHasher()) - - def decryptable_by(self, user): - """ - Check whether the given user has permission to decrypt this Secret. - """ - return self.role.has_member(user) diff --git a/netbox/secrets/tables.py b/netbox/secrets/tables.py index f92c9216b..0d8559a2b 100644 --- a/netbox/secrets/tables.py +++ b/netbox/secrets/tables.py @@ -1,17 +1,8 @@ import django_tables2 as tables -from utilities.tables import BaseTable, TagColumn, ToggleColumn +from utilities.tables import BaseTable, ButtonsColumn, LinkedCountColumn, TagColumn, ToggleColumn from .models import SecretRole, Secret -SECRETROLE_ACTIONS = """ - - - -{% if perms.secrets.change_secretrole %} - -{% endif %} -""" - # # Secret roles @@ -20,18 +11,16 @@ SECRETROLE_ACTIONS = """ class SecretRoleTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn() - secret_count = tables.Column( + secret_count = LinkedCountColumn( + viewname='secrets:secret_list', + url_params={'role': 'slug'}, verbose_name='Secrets' ) - actions = tables.TemplateColumn( - template_code=SECRETROLE_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' - ) + actions = ButtonsColumn(SecretRole, pk_field='slug') class Meta(BaseTable.Meta): model = SecretRole - fields = ('pk', 'name', 'secret_count', 'description', 'slug', 'users', 'groups', 'actions') + fields = ('pk', 'name', 'secret_count', 'description', 'slug', 'actions') default_columns = ('pk', 'name', 'secret_count', 'description', 'actions') @@ -41,12 +30,21 @@ class SecretRoleTable(BaseTable): class SecretTable(BaseTable): pk = ToggleColumn() - device = tables.LinkColumn() + id = tables.Column( # Provides a link to the secret + linkify=True + ) + assigned_object = tables.Column( + linkify=True, + verbose_name='Assigned object' + ) + role = tables.Column( + linkify=True + ) tags = TagColumn( url_name='secrets:secret_list' ) class Meta(BaseTable.Meta): model = Secret - fields = ('pk', 'device', 'role', 'name', 'last_updated', 'hash', 'tags') - default_columns = ('pk', 'device', 'role', 'name', 'last_updated') + fields = ('pk', 'id', 'assigned_object', 'role', 'name', 'last_updated', 'hash', 'tags') + default_columns = ('pk', 'id', 'assigned_object', 'role', 'name', 'last_updated') diff --git a/netbox/secrets/templatetags/secret_helpers.py b/netbox/secrets/templatetags/secret_helpers.py deleted file mode 100644 index 142c0d2cb..000000000 --- a/netbox/secrets/templatetags/secret_helpers.py +++ /dev/null @@ -1,12 +0,0 @@ -from django import template - - -register = template.Library() - - -@register.filter() -def decryptable_by(secret, user): - """ - Determine whether a given User is permitted to decrypt a Secret. - """ - return secret.decryptable_by(user) diff --git a/netbox/secrets/tests/test_api.py b/netbox/secrets/tests/test_api.py index 339c370d8..34608d68b 100644 --- a/netbox/secrets/tests/test_api.py +++ b/netbox/secrets/tests/test_api.py @@ -5,8 +5,7 @@ from rest_framework import status from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from secrets.models import Secret, SecretRole, SessionKey, UserKey -from users.models import Token -from utilities.testing import APITestCase, create_test_user +from utilities.testing import APITestCase, APIViewTestCases from .constants import PRIVATE_KEY, PUBLIC_KEY @@ -20,271 +19,110 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) -class SecretRoleTest(APITestCase): +class SecretRoleTest(APIViewTestCases.APIViewTestCase): + model = SecretRole + brief_fields = ['id', 'name', 'secret_count', 'slug', 'url'] + create_data = [ + { + 'name': 'Secret Role 4', + 'slug': 'secret-role-4', + }, + { + 'name': 'Secret Role 5', + 'slug': 'secret-role-5', + }, + { + 'name': 'Secret Role 6', + 'slug': 'secret-role-6', + }, + ] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + + secret_roles = ( + 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'), + ) + SecretRole.objects.bulk_create(secret_roles) + + +class SecretTest(APIViewTestCases.APIViewTestCase): + model = Secret + brief_fields = ['id', 'name', 'url'] def setUp(self): - 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') - self.secretrole3 = SecretRole.objects.create(name='Test Secret Role 3', slug='test-secret-role-3') - - def test_get_secretrole(self): - - url = reverse('secrets-api:secretrole-detail', kwargs={'pk': self.secretrole1.pk}) - response = self.client.get(url, **self.header) - - self.assertEqual(response.data['name'], self.secretrole1.name) - - def test_list_secretroles(self): - - url = reverse('secrets-api:secretrole-list') - response = self.client.get(url, **self.header) - - self.assertEqual(response.data['count'], 3) - - def test_list_secretroles_brief(self): - - url = reverse('secrets-api:secretrole-list') - response = self.client.get('{}?brief=1'.format(url), **self.header) - - self.assertEqual( - sorted(response.data['results'][0]), - ['id', 'name', 'secret_count', 'slug', 'url'] - ) - - def test_create_secretrole(self): - - data = { - 'name': 'Test Secret Role 4', - 'slug': 'test-secret-role-4', - } - - url = reverse('secrets-api:secretrole-list') - response = self.client.post(url, data, format='json', **self.header) - - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(SecretRole.objects.count(), 4) - secretrole4 = SecretRole.objects.get(pk=response.data['id']) - self.assertEqual(secretrole4.name, data['name']) - self.assertEqual(secretrole4.slug, data['slug']) - - def test_create_secretrole_bulk(self): - - data = [ - { - 'name': 'Test Secret Role 4', - 'slug': 'test-secret-role-4', - }, - { - 'name': 'Test Secret Role 5', - 'slug': 'test-secret-role-5', - }, - { - 'name': 'Test Secret Role 6', - 'slug': 'test-secret-role-6', - }, - ] - - url = reverse('secrets-api:secretrole-list') - response = self.client.post(url, data, format='json', **self.header) - - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(SecretRole.objects.count(), 6) - self.assertEqual(response.data[0]['name'], data[0]['name']) - self.assertEqual(response.data[1]['name'], data[1]['name']) - self.assertEqual(response.data[2]['name'], data[2]['name']) - - def test_update_secretrole(self): - - data = { - 'name': 'Test SecretRole X', - 'slug': 'test-secretrole-x', - } - - url = reverse('secrets-api:secretrole-detail', kwargs={'pk': self.secretrole1.pk}) - response = self.client.put(url, data, format='json', **self.header) - - self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(SecretRole.objects.count(), 3) - secretrole1 = SecretRole.objects.get(pk=response.data['id']) - self.assertEqual(secretrole1.name, data['name']) - self.assertEqual(secretrole1.slug, data['slug']) - - def test_delete_secretrole(self): - - url = reverse('secrets-api:secretrole-detail', kwargs={'pk': self.secretrole1.pk}) - response = self.client.delete(url, **self.header) - - self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) - self.assertEqual(SecretRole.objects.count(), 2) - - -class SecretTest(APITestCase): - - def setUp(self): - - # Create a non-superuser test user - self.user = create_test_user('testuser', permissions=( - 'secrets.add_secret', - 'secrets.change_secret', - 'secrets.delete_secret', - 'secrets.view_secret', - )) - self.token = Token.objects.create(user=self.user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)} - + # Create a UserKey for the test user userkey = UserKey(user=self.user, public_key=PUBLIC_KEY) userkey.save() + + # Create a SessionKey for the user self.master_key = userkey.get_master_key(PRIVATE_KEY) session_key = SessionKey(userkey=userkey) session_key.save(self.master_key) - self.header = { - 'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key), - 'HTTP_X_SESSION_KEY': base64.b64encode(session_key.key), - } + # Append the session key to the test client's request header + self.header['HTTP_X_SESSION_KEY'] = base64.b64encode(session_key.key) - self.plaintexts = ( - 'Secret #1 Plaintext', - 'Secret #2 Plaintext', - 'Secret #3 Plaintext', + site = Site.objects.create(name='Site 1', slug='site-1') + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1') + devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, device_role=devicerole) + + secret_roles = ( + SecretRole(name='Secret Role 1', slug='secret-role-1'), + SecretRole(name='Secret Role 2', slug='secret-role-2'), ) + SecretRole.objects.bulk_create(secret_roles) - 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') - devicerole = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1') - self.device = Device.objects.create( - name='Test Device 1', site=site, device_type=devicetype, device_role=devicerole + secrets = ( + Secret(assigned_object=device, role=secret_roles[0], name='Secret 1', plaintext='ABC'), + Secret(assigned_object=device, role=secret_roles[0], name='Secret 2', plaintext='DEF'), + Secret(assigned_object=device, role=secret_roles[0], name='Secret 3', plaintext='GHI'), ) - 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') - self.secret1 = Secret( - device=self.device, role=self.secretrole1, name='Test Secret 1', plaintext=self.plaintexts[0] - ) - self.secret1.encrypt(self.master_key) - self.secret1.save() - self.secret2 = Secret( - device=self.device, role=self.secretrole1, name='Test Secret 2', plaintext=self.plaintexts[1] - ) - self.secret2.encrypt(self.master_key) - self.secret2.save() - self.secret3 = Secret( - device=self.device, role=self.secretrole1, name='Test Secret 3', plaintext=self.plaintexts[2] - ) - self.secret3.encrypt(self.master_key) - self.secret3.save() + for secret in secrets: + secret.encrypt(self.master_key) + secret.save() - def test_get_secret(self): - - url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk}) - - # Secret plaintext not be decrypted as the user has not been assigned to the role - response = self.client.get(url, **self.header) - self.assertIsNone(response.data['plaintext']) - - # The plaintext should be present once the user has been assigned to the role - self.secretrole1.users.add(self.user) - response = self.client.get(url, **self.header) - self.assertEqual(response.data['plaintext'], self.plaintexts[0]) - - def test_list_secrets(self): - - url = reverse('secrets-api:secret-list') - - # Secret plaintext not be decrypted as the user has not been assigned to the role - response = self.client.get(url, **self.header) - self.assertEqual(response.data['count'], 3) - for secret in response.data['results']: - self.assertIsNone(secret['plaintext']) - - # The plaintext should be present once the user has been assigned to the role - self.secretrole1.users.add(self.user) - response = self.client.get(url, **self.header) - self.assertEqual(response.data['count'], 3) - for i, secret in enumerate(response.data['results']): - self.assertEqual(secret['plaintext'], self.plaintexts[i]) - - def test_create_secret(self): - - data = { - 'device': self.device.pk, - 'role': self.secretrole1.pk, - 'name': 'Test Secret 4', - 'plaintext': 'Secret #4 Plaintext', - } - - url = reverse('secrets-api:secret-list') - response = self.client.post(url, data, format='json', **self.header) - - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(response.data['plaintext'], data['plaintext']) - self.assertEqual(Secret.objects.count(), 4) - secret4 = Secret.objects.get(pk=response.data['id']) - secret4.decrypt(self.master_key) - self.assertEqual(secret4.role_id, data['role']) - self.assertEqual(secret4.plaintext, data['plaintext']) - - def test_create_secret_bulk(self): - - data = [ + self.create_data = [ { - 'device': self.device.pk, - 'role': self.secretrole1.pk, - 'name': 'Test Secret 4', - 'plaintext': 'Secret #4 Plaintext', + 'assigned_object_type': 'dcim.device', + 'assigned_object_id': device.pk, + 'role': secret_roles[1].pk, + 'name': 'Secret 4', + 'plaintext': 'JKL', }, { - 'device': self.device.pk, - 'role': self.secretrole1.pk, - 'name': 'Test Secret 5', - 'plaintext': 'Secret #5 Plaintext', + 'assigned_object_type': 'dcim.device', + 'assigned_object_id': device.pk, + 'role': secret_roles[1].pk, + 'name': 'Secret 5', + 'plaintext': 'MNO', }, { - 'device': self.device.pk, - 'role': self.secretrole1.pk, - 'name': 'Test Secret 6', - 'plaintext': 'Secret #6 Plaintext', + 'assigned_object_type': 'dcim.device', + 'assigned_object_id': device.pk, + 'role': secret_roles[1].pk, + 'name': 'Secret 6', + 'plaintext': 'PQR', }, ] - url = reverse('secrets-api:secret-list') - response = self.client.post(url, data, format='json', **self.header) - - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(Secret.objects.count(), 6) - self.assertEqual(response.data[0]['plaintext'], data[0]['plaintext']) - self.assertEqual(response.data[1]['plaintext'], data[1]['plaintext']) - self.assertEqual(response.data[2]['plaintext'], data[2]['plaintext']) - - def test_update_secret(self): - - data = { - 'device': self.device.pk, - 'role': self.secretrole2.pk, - 'plaintext': 'NewPlaintext', + self.bulk_update_data = { + 'role': secret_roles[1].pk, } - url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk}) - response = self.client.put(url, data, format='json', **self.header) - - self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(response.data['plaintext'], data['plaintext']) - self.assertEqual(Secret.objects.count(), 3) - secret1 = Secret.objects.get(pk=response.data['id']) - secret1.decrypt(self.master_key) - self.assertEqual(secret1.role_id, data['role']) - self.assertEqual(secret1.plaintext, data['plaintext']) - - def test_delete_secret(self): - - url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk}) - response = self.client.delete(url, **self.header) - - self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) - self.assertEqual(Secret.objects.count(), 2) + def prepare_instance(self, instance): + # Unlock the plaintext prior to evaluation of the instance + instance.decrypt(self.master_key) + return instance class GetSessionKeyTest(APITestCase): diff --git a/netbox/secrets/tests/test_filters.py b/netbox/secrets/tests/test_filters.py index b7ac73f1d..0be1ef594 100644 --- a/netbox/secrets/tests/test_filters.py +++ b/netbox/secrets/tests/test_filters.py @@ -3,6 +3,7 @@ from django.test import TestCase from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from secrets.filters import * from secrets.models import Secret, SecretRole +from virtualization.models import Cluster, ClusterType, VirtualMachine class SecretRoleTestCase(TestCase): @@ -51,6 +52,15 @@ class SecretTestCase(TestCase): ) Device.objects.bulk_create(devices) + cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') + cluster = Cluster.objects.create(name='Cluster 1', type=cluster_type) + virtual_machines = ( + VirtualMachine(name='Virtual Machine 1', cluster=cluster), + VirtualMachine(name='Virtual Machine 2', cluster=cluster), + VirtualMachine(name='Virtual Machine 3', cluster=cluster), + ) + VirtualMachine.objects.bulk_create(virtual_machines) + roles = ( SecretRole(name='Secret Role 1', slug='secret-role-1'), SecretRole(name='Secret Role 2', slug='secret-role-2'), @@ -59,9 +69,12 @@ class SecretTestCase(TestCase): SecretRole.objects.bulk_create(roles) secrets = ( - Secret(device=devices[0], role=roles[0], name='Secret 1', plaintext='SECRET DATA'), - Secret(device=devices[1], role=roles[1], name='Secret 2', plaintext='SECRET DATA'), - Secret(device=devices[2], role=roles[2], name='Secret 3', plaintext='SECRET DATA'), + Secret(assigned_object=devices[0], role=roles[0], name='Secret 1', plaintext='SECRET DATA'), + Secret(assigned_object=devices[1], role=roles[1], name='Secret 2', plaintext='SECRET DATA'), + Secret(assigned_object=devices[2], role=roles[2], name='Secret 3', plaintext='SECRET DATA'), + Secret(assigned_object=virtual_machines[0], role=roles[0], name='Secret 4', plaintext='SECRET DATA'), + Secret(assigned_object=virtual_machines[1], role=roles[1], name='Secret 5', plaintext='SECRET DATA'), + Secret(assigned_object=virtual_machines[2], role=roles[2], name='Secret 6', plaintext='SECRET DATA'), ) # Must call save() to encrypt Secrets for s in secrets: @@ -78,9 +91,9 @@ class SecretTestCase(TestCase): def test_role(self): roles = SecretRole.objects.all()[:2] params = {'role_id': [roles[0].pk, roles[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) params = {'role': [roles[0].slug, roles[1].slug]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_device(self): devices = Device.objects.all()[:2] @@ -88,3 +101,10 @@ class SecretTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) params = {'device': [devices[0].name, devices[1].name]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_virtual_machine(self): + virtual_machines = VirtualMachine.objects.all()[:2] + params = {'virtual_machine_id': [virtual_machines[0].pk, virtual_machines[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'virtual_machine': [virtual_machines[0].name, virtual_machines[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/secrets/tests/test_views.py b/netbox/secrets/tests/test_views.py index 96439a10d..bff039656 100644 --- a/netbox/secrets/tests/test_views.py +++ b/netbox/secrets/tests/test_views.py @@ -1,5 +1,6 @@ import base64 +from django.test import override_settings from django.urls import reverse from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site @@ -24,8 +25,6 @@ class SecretRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'name': 'Secret Role X', 'slug': 'secret-role-x', 'description': 'A secret role', - 'users': [], - 'groups': [], } cls.csv_data = ( @@ -36,15 +35,17 @@ class SecretRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): ) -class SecretTestCase(ViewTestCases.PrimaryObjectViewTestCase): +# TODO: Change base class to PrimaryObjectViewTestCase +class SecretTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.GetObjectChangelogViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkEditObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase +): model = Secret - # Disable inapplicable tests - test_create_object = None - - # TODO: Check permissions enforcement on secrets.views.secret_edit - test_edit_object = None - @classmethod def setUpTestData(cls): @@ -68,13 +69,14 @@ class SecretTestCase(ViewTestCases.PrimaryObjectViewTestCase): # Create one secret per device to allow bulk-editing of names (which must be unique per device/role) Secret.objects.bulk_create(( - Secret(device=devices[0], role=secretroles[0], name='Secret 1', ciphertext=b'1234567890'), - Secret(device=devices[1], role=secretroles[0], name='Secret 2', ciphertext=b'1234567890'), - Secret(device=devices[2], role=secretroles[0], name='Secret 3', ciphertext=b'1234567890'), + Secret(assigned_object=devices[0], role=secretroles[0], name='Secret 1', ciphertext=b'1234567890'), + Secret(assigned_object=devices[1], role=secretroles[0], name='Secret 2', ciphertext=b'1234567890'), + Secret(assigned_object=devices[2], role=secretroles[0], name='Secret 3', ciphertext=b'1234567890'), )) cls.form_data = { - 'device': devices[1].pk, + 'assigned_object_type': 'dcim.device', + 'assigned_object_id': devices[1].pk, 'role': secretroles[1].pk, 'name': 'Secret X', } @@ -95,14 +97,16 @@ class SecretTestCase(ViewTestCases.PrimaryObjectViewTestCase): self.session_key = SessionKey(userkey=userkey) self.session_key.save(master_key) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_import_objects(self): self.add_permissions('secrets.add_secret') + device = Device.objects.get(name='Device 1') csv_data = ( "device,role,name,plaintext", - "Device 1,Secret Role 1,Secret 4,abcdefghij", - "Device 1,Secret Role 1,Secret 5,abcdefghij", - "Device 1,Secret Role 1,Secret 6,abcdefghij", + f"{device.name},Secret Role 1,Secret 4,abcdefghij", + f"{device.name},Secret Role 1,Secret 5,abcdefghij", + f"{device.name},Secret Role 1,Secret 6,abcdefghij", ) # Set the session_key cookie on the request diff --git a/netbox/secrets/urls.py b/netbox/secrets/urls.py index a19ec6ae0..9dbb5d044 100644 --- a/netbox/secrets/urls.py +++ b/netbox/secrets/urls.py @@ -9,20 +9,21 @@ urlpatterns = [ # Secret roles path('secret-roles/', views.SecretRoleListView.as_view(), name='secretrole_list'), - path('secret-roles/add/', views.SecretRoleCreateView.as_view(), name='secretrole_add'), + path('secret-roles/add/', views.SecretRoleEditView.as_view(), name='secretrole_add'), path('secret-roles/import/', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'), path('secret-roles/delete/', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'), path('secret-roles//edit/', views.SecretRoleEditView.as_view(), name='secretrole_edit'), + path('secret-roles//delete/', views.SecretRoleDeleteView.as_view(), name='secretrole_delete'), path('secret-roles//changelog/', ObjectChangeLogView.as_view(), name='secretrole_changelog', kwargs={'model': SecretRole}), # Secrets path('secrets/', views.SecretListView.as_view(), name='secret_list'), - path('secrets/add/', views.secret_add, name='secret_add'), + path('secrets/add/', views.SecretEditView.as_view(), name='secret_add'), path('secrets/import/', views.SecretBulkImportView.as_view(), name='secret_import'), path('secrets/edit/', views.SecretBulkEditView.as_view(), name='secret_bulk_edit'), path('secrets/delete/', views.SecretBulkDeleteView.as_view(), name='secret_bulk_delete'), path('secrets//', views.SecretView.as_view(), name='secret'), - path('secrets//edit/', views.secret_edit, name='secret_edit'), + path('secrets//edit/', views.SecretEditView.as_view(), name='secret_edit'), path('secrets//delete/', views.SecretDeleteView.as_view(), name='secret_delete'), path('secrets//changelog/', ObjectChangeLogView.as_view(), name='secret_changelog', kwargs={'model': Secret}), diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index ed59f4392..3fb8d1740 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -1,19 +1,15 @@ import base64 +import logging from django.contrib import messages -from django.contrib.auth.decorators import permission_required -from django.contrib.auth.mixins import PermissionRequiredMixin -from django.db.models import Count from django.shortcuts import get_object_or_404, redirect, render -from django.urls import reverse -from django.views.generic import View +from django.utils.html import escape +from django.utils.safestring import mark_safe -from utilities.views import ( - BulkDeleteView, BulkEditView, BulkImportView, GetReturnURLMixin, ObjectDeleteView, ObjectEditView, ObjectListView, -) +from netbox.views import generic +from utilities.utils import count_related from . import filters, forms, tables -from .decorators import userkey_required -from .models import SecretRole, Secret, SessionKey +from .models import SecretRole, Secret, SessionKey, UserKey def get_session_key(request): @@ -30,177 +26,129 @@ def get_session_key(request): # Secret roles # -class SecretRoleListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'secrets.view_secretrole' - queryset = SecretRole.objects.annotate(secret_count=Count('secrets')) +class SecretRoleListView(generic.ObjectListView): + queryset = SecretRole.objects.annotate( + secret_count=count_related(Secret, 'role') + ) table = tables.SecretRoleTable -class SecretRoleCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'secrets.add_secretrole' - model = SecretRole +class SecretRoleEditView(generic.ObjectEditView): + queryset = SecretRole.objects.all() model_form = forms.SecretRoleForm - default_return_url = 'secrets:secretrole_list' -class SecretRoleEditView(SecretRoleCreateView): - permission_required = 'secrets.change_secretrole' +class SecretRoleDeleteView(generic.ObjectDeleteView): + queryset = SecretRole.objects.all() -class SecretRoleBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'secrets.add_secretrole' +class SecretRoleBulkImportView(generic.BulkImportView): + queryset = SecretRole.objects.all() model_form = forms.SecretRoleCSVForm table = tables.SecretRoleTable - default_return_url = 'secrets:secretrole_list' -class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'secrets.delete_secretrole' - queryset = SecretRole.objects.annotate(secret_count=Count('secrets')) +class SecretRoleBulkDeleteView(generic.BulkDeleteView): + queryset = SecretRole.objects.annotate( + secret_count=count_related(Secret, 'role') + ) table = tables.SecretRoleTable - default_return_url = 'secrets:secretrole_list' # # Secrets # -class SecretListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'secrets.view_secret' - queryset = Secret.objects.prefetch_related('role', 'device') +class SecretListView(generic.ObjectListView): + queryset = Secret.objects.all() filterset = filters.SecretFilterSet filterset_form = forms.SecretFilterForm table = tables.SecretTable action_buttons = ('import', 'export') -class SecretView(PermissionRequiredMixin, View): - permission_required = 'secrets.view_secret' +class SecretView(generic.ObjectView): + queryset = Secret.objects.all() - def get(self, request, pk): - secret = get_object_or_404(Secret, pk=pk) +class SecretEditView(generic.ObjectEditView): + queryset = Secret.objects.all() + model_form = forms.SecretForm + template_name = 'secrets/secret_edit.html' - return render(request, 'secrets/secret.html', { - 'secret': secret, + def dispatch(self, request, *args, **kwargs): + + # Check that the user has a valid UserKey + try: + uk = UserKey.objects.get(user=request.user) + except UserKey.DoesNotExist: + messages.warning(request, "This operation requires an active user key, but you don't have one.") + return redirect('user:userkey') + if not uk.is_active(): + messages.warning(request, "This operation is not available. Your user key has not been activated.") + return redirect('user:userkey') + + return super().dispatch(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + logger = logging.getLogger('netbox.views.ObjectEditView') + session_key = get_session_key(request) + secret = self.get_object(kwargs) + form = self.model_form(request.POST, instance=secret) + + if form.is_valid(): + logger.debug("Form validation was successful") + secret = form.save(commit=False) + + # We must have a session key in order to set the plaintext of a Secret + if form.cleaned_data['plaintext'] and session_key is None: + logger.debug("Unable to proceed: No session key was provided with the request") + form.add_error(None, "No session key was provided with the request. Unable to encrypt secret data.") + + elif form.cleaned_data['plaintext']: + master_key = None + try: + sk = SessionKey.objects.get(userkey__user=request.user) + master_key = sk.get_master_key(session_key) + except SessionKey.DoesNotExist: + logger.debug("Unable to proceed: User has no session key assigned") + form.add_error(None, "No session key found for this user.") + + if master_key is not None: + logger.debug("Successfully resolved master key for encryption") + secret.plaintext = str(form.cleaned_data['plaintext']) + secret.encrypt(master_key) + + secret.save() + form.save_m2m() + + msg = '{} secret'.format('Created' if not form.instance.pk else 'Modified') + logger.info(f"{msg} {secret} (PK: {secret.pk})") + msg = f'{msg} {escape(secret)}' + messages.success(request, mark_safe(msg)) + + return redirect(self.get_return_url(request, secret)) + + else: + logger.debug("Form validation failed") + + return render(request, self.template_name, { + 'obj': secret, + 'obj_type': self.queryset.model._meta.verbose_name, + 'form': form, + 'return_url': self.get_return_url(request, secret), }) -@permission_required('secrets.add_secret') -@userkey_required() -def secret_add(request): - - secret = Secret() - session_key = get_session_key(request) - - if request.method == 'POST': - form = forms.SecretForm(request.POST, instance=secret) - if form.is_valid(): - - # We need a valid session key in order to create a Secret - if session_key is None: - form.add_error(None, "No session key was provided with the request. Unable to encrypt secret data.") - - # Create and encrypt the new Secret - else: - master_key = None - try: - sk = SessionKey.objects.get(userkey__user=request.user) - master_key = sk.get_master_key(session_key) - except SessionKey.DoesNotExist: - form.add_error(None, "No session key found for this user.") - - if master_key is not None: - secret = form.save(commit=False) - 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('secrets:secret_add') - else: - return redirect('secrets:secret', pk=secret.pk) - - else: - initial_data = { - 'device': request.GET.get('device'), - } - form = forms.SecretForm(initial=initial_data) - - return render(request, 'secrets/secret_edit.html', { - 'secret': secret, - 'form': form, - 'return_url': GetReturnURLMixin().get_return_url(request, secret) - }) +class SecretDeleteView(generic.ObjectDeleteView): + queryset = Secret.objects.all() -@permission_required('secrets.change_secret') -@userkey_required() -def secret_edit(request, pk): - - secret = get_object_or_404(Secret, pk=pk) - session_key = get_session_key(request) - - if request.method == 'POST': - form = forms.SecretForm(request.POST, instance=secret) - if form.is_valid(): - - # Re-encrypt the Secret if a plaintext and session key have been provided. - if form.cleaned_data['plaintext'] and session_key is not None: - - # Retrieve the master key using the provided session key - master_key = None - try: - sk = SessionKey.objects.get(userkey__user=request.user) - master_key = sk.get_master_key(session_key) - except SessionKey.DoesNotExist: - form.add_error(None, "No session key found for this user.") - - # Create and encrypt the new Secret - if master_key is not None: - secret = form.save(commit=False) - secret.plaintext = form.cleaned_data['plaintext'] - secret.encrypt(master_key) - secret.save() - messages.success(request, "Modified secret {}.".format(secret)) - return redirect('secrets:secret', pk=secret.pk) - else: - form.add_error(None, "Invalid session key. Unable to encrypt secret data.") - - # We can't save the plaintext without a session key. - elif form.cleaned_data['plaintext']: - form.add_error(None, "No session key was provided with the request. Unable to encrypt secret data.") - - # If no new plaintext was specified, a session key is not needed. - else: - secret = form.save() - messages.success(request, "Modified secret {}.".format(secret)) - return redirect('secrets:secret', pk=secret.pk) - - else: - form = forms.SecretForm(instance=secret) - - return render(request, 'secrets/secret_edit.html', { - 'secret': secret, - 'form': form, - 'return_url': reverse('secrets:secret', kwargs={'pk': secret.pk}), - }) - - -class SecretDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'secrets.delete_secret' - model = Secret - default_return_url = 'secrets:secret_list' - - -class SecretBulkImportView(BulkImportView): - permission_required = 'secrets.add_secret' +class SecretBulkImportView(generic.BulkImportView): + queryset = Secret.objects.all() model_form = forms.SecretCSVForm table = tables.SecretTable template_name = 'secrets/secret_import.html' - default_return_url = 'secrets:secret_list' widget_attrs = {'class': 'requires-session-key'} master_key = None @@ -243,18 +191,14 @@ class SecretBulkImportView(BulkImportView): }) -class SecretBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'secrets.change_secret' - queryset = Secret.objects.prefetch_related('role', 'device') +class SecretBulkEditView(generic.BulkEditView): + queryset = Secret.objects.prefetch_related('role') filterset = filters.SecretFilterSet table = tables.SecretTable form = forms.SecretBulkEditForm - default_return_url = 'secrets:secret_list' -class SecretBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'secrets.delete_secret' - queryset = Secret.objects.prefetch_related('role', 'device') +class SecretBulkDeleteView(generic.BulkDeleteView): + queryset = Secret.objects.prefetch_related('role') filterset = filters.SecretFilterSet table = tables.SecretTable - default_return_url = 'secrets:secret_list' diff --git a/netbox/templates/403.html b/netbox/templates/403.html new file mode 100644 index 000000000..844697aae --- /dev/null +++ b/netbox/templates/403.html @@ -0,0 +1,9 @@ +{% extends '40x.html' %} + +{% block title %}Access Denied{% endblock %} + +{% block icon %}{% endblock %} + +{% block message %} + You do not have permission to access this page. +{% endblock %} diff --git a/netbox/templates/404.html b/netbox/templates/404.html index f2fe6b430..eec438071 100644 --- a/netbox/templates/404.html +++ b/netbox/templates/404.html @@ -1,19 +1,9 @@ -{% extends 'base.html' %} +{% extends '40x.html' %} -{% block content %} -
        -
        -
        -
        - Page Not Found -
        -
        - The requested page does not exist. -
        - -
        -
        -
        +{% block title %}Page Not Found{% endblock %} + +{% block icon %}{% endblock %} + +{% block message %} + The requested page does not exist. {% endblock %} diff --git a/netbox/templates/40x.html b/netbox/templates/40x.html new file mode 100644 index 000000000..d923d001e --- /dev/null +++ b/netbox/templates/40x.html @@ -0,0 +1,19 @@ +{% extends 'base.html' %} + +{% block content %} +
        +
        +
        +
        + {% block icon %}{% endblock %} {% block title %}{% endblock %} +
        +
        + {% block message %}{% endblock %} +
        + +
        +
        +
        +{% endblock %} diff --git a/netbox/templates/500.html b/netbox/templates/500.html index bd59b7233..8345d00c4 100644 --- a/netbox/templates/500.html +++ b/netbox/templates/500.html @@ -5,7 +5,7 @@ Server Error - + @@ -16,7 +16,7 @@
        - + Server Error
        @@ -31,9 +31,12 @@ The complete exception is provided below:

        {{ exception }}
        -{{ error }}
        +{{ error }} + +Python version: {{ python_version }} +NetBox version: {{ netbox_version }}

        - If further assistance is required, please post to the NetBox mailing list. + If further assistance is required, please post to the NetBox mailing list.

        Home Page diff --git a/netbox/templates/base.html b/netbox/templates/base.html index 6125e4614..f3129d7dd 100644 --- a/netbox/templates/base.html +++ b/netbox/templates/base.html @@ -8,14 +8,14 @@ href="{% static 'bootstrap-3.4.1-dist/css/bootstrap.min.css' %}" onerror="window.location='{% url 'media_failure' %}?filename=bootstrap-3.4.1-dist/css/bootstrap.min.css'"> + href="{% static 'materialdesignicons-5.4.55/css/materialdesignicons.min.css' %}" + onerror="window.location='{% url 'media_failure' %}?filename=materialdesignicons-5.4.55/css/materialdesignicons.min.css'"> + href="{% static 'select2-4.0.13/dist/css/select2.min.css' %}" + onerror="window.location='{% url 'media_failure' %}?filename=select2-4.0.13/dist/css/select2.min.css'"> @@ -31,7 +31,7 @@ {% include 'inc/nav_menu.html' %} -
        +
        {% if settings.BANNER_TOP %} - + - - + + -{% endblock %} diff --git a/netbox/templates/circuits/provider_edit.html b/netbox/templates/circuits/provider_edit.html index 63b7f11b9..ee0a3a285 100644 --- a/netbox/templates/circuits/provider_edit.html +++ b/netbox/templates/circuits/provider_edit.html @@ -1,4 +1,4 @@ -{% extends 'utilities/obj_edit.html' %} +{% extends 'generic/object_edit.html' %} {% load form_helpers %} {% block form %} diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html index e6a2fa008..3bc7869fe 100644 --- a/netbox/templates/dcim/cable.html +++ b/netbox/templates/dcim/cable.html @@ -9,31 +9,31 @@
        - {% plugin_buttons cable %} + {% plugin_buttons object %} {% if perms.dcim.change_cable %} - {% edit_button cable %} + {% edit_button object %} {% endif %} {% if perms.dcim.delete_cable %} - {% delete_button cable %} + {% delete_button object %} {% endif %}
        -

        {% block title %}Cable {{ cable }}{% endblock %}

        - {% include 'inc/created_updated.html' with obj=cable %} +

        {% block title %}Cable {{ object }}{% endblock %}

        + {% include 'inc/created_updated.html' %}
        - {% custom_links cable %} + {% custom_links object %}
        @@ -49,21 +49,23 @@ - + - + - +
        Type{{ cable.get_type_display }}{{ object.get_type_display|placeholder }}
        Status{{ cable.get_status_display }} + {{ object.get_status_display }} +
        Label{{ cable.label|placeholder }}{{ object.label|placeholder }}
        Color - {% if cable.color %} -   + {% if object.color %} +   {% else %} {% endif %} @@ -72,8 +74,8 @@
        Length - {% if cable.length %} - {{ cable.length }} {{ cable.get_length_unit_display }} + {% if object.length %} + {{ object.length }} {{ object.get_length_unit_display }} {% else %} {% endif %} @@ -81,27 +83,29 @@
        - {% plugin_left_page cable %} + {% include 'inc/custom_fields_panel.html' %} + {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:cable_list' %} + {% plugin_left_page object %}
        Termination A
        - {% include 'dcim/inc/cable_termination.html' with termination=cable.termination_a %} + {% include 'dcim/inc/cable_termination.html' with termination=object.termination_a %}
        Termination B
        - {% include 'dcim/inc/cable_termination.html' with termination=cable.termination_b %} + {% include 'dcim/inc/cable_termination.html' with termination=object.termination_b %}
        - {% plugin_right_page cable %} + {% plugin_right_page object %}
        - {% plugin_full_width_page cable %} + {% plugin_full_width_page object %}
        {% endblock %} diff --git a/netbox/templates/dcim/cable_connect.html b/netbox/templates/dcim/cable_connect.html index 1a7c9411e..f8e07dca8 100644 --- a/netbox/templates/dcim/cable_connect.html +++ b/netbox/templates/dcim/cable_connect.html @@ -32,6 +32,12 @@
        {% if termination_a.device %} {# Device component #} +
        + +
        +

        {{ termination_a.device.site.region }}

        +
        +
        @@ -93,7 +99,7 @@
        - +
        @@ -111,6 +117,9 @@ {% if 'termination_b_provider' in form.fields %} {% render_field form.termination_b_provider %} {% endif %} + {% if 'termination_b_region' in form.fields %} + {% render_field form.termination_b_region %} + {% endif %} {% if 'termination_b_site' in form.fields %} {% render_field form.termination_b_site %} {% endif %} @@ -157,7 +166,3 @@ {% endwith %} {% endblock %} - -{% block javascript %} - -{% endblock %} diff --git a/netbox/templates/dcim/cable_edit.html b/netbox/templates/dcim/cable_edit.html index 685b68206..3bdce58a3 100644 --- a/netbox/templates/dcim/cable_edit.html +++ b/netbox/templates/dcim/cable_edit.html @@ -1,4 +1,4 @@ -{% extends 'utilities/obj_edit.html' %} +{% extends 'generic/object_edit.html' %} {% block form %} {% include 'dcim/inc/cable_form.html' %} diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html index 1e7210e9a..a36922612 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -2,96 +2,128 @@ {% load helpers %} {% block header %} -

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

        +

        {% block title %}Cable Trace for {{ object|meta:"verbose_name"|bettertitle }} {{ object }}{% endblock %}

        {% endblock %} {% block content %}
        -
        -

        Near End

        -
        -
        - {% if total_length %}
        Total length: {{ total_length|floatformat:"-2" }} Meters
        {% endif %} -
        -
        -

        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 %} - {% if cable.color %} -   - {% endif %} - {% else %} -

        No Cable

        - {% endif %} -
        -
        - {% if far_end %} - {% include 'dcim/inc/cable_trace_end.html' with end=far_end %} - {% endif %} +
        +
        + {% with traced_path=path.origin.trace %} + {% for near_end, cable, far_end in traced_path %} + + {# Near end #} + {% if near_end.device %} + {% include 'dcim/trace/device.html' with device=near_end.device %} + {% include 'dcim/trace/termination.html' with termination=near_end %} + {% elif near_end.power_panel %} + {% include 'dcim/trace/powerpanel.html' with powerpanel=near_end.power_panel %} + {% include 'dcim/trace/termination.html' with termination=far_end%} + {% elif near_end.circuit %} + {% include 'dcim/trace/circuit.html' with circuit=near_end.circuit %} + {% include 'dcim/trace/termination.html' with termination=near_end %} + {% endif %} + + {# Cable #} + {% if cable %} + {% include 'dcim/trace/cable.html' %} + {% endif %} + + {# Far end #} + {% if far_end.device %} + {% include 'dcim/trace/termination.html' with termination=far_end %} + {% if forloop.last %} + {% include 'dcim/trace/device.html' with device=far_end.device %} + {% endif %} + {% elif far_end.power_panel %} + {% include 'dcim/trace/termination.html' with termination=far_end %} + {% include 'dcim/trace/powerpanel.html' with powerpanel=far_end.power_panel %} + {% elif far_end.circuit %} + {% include 'dcim/trace/termination.html' with termination=far_end %} + {% if forloop.last %} + {% include 'dcim/trace/circuit.html' with circuit=far_end.circuit %} + {% endif %} + {% endif %} + + {% if forloop.last %} + {% if path.is_split %} +
        +

        Path split!

        +

        Select a node below to continue:

        +
          + {% for next_node in path.get_split_nodes %} + {% if next_node.cable %} +
        • + {{ next_node }} + (Cable {{ next_node.cable }}) +
        • + {% else %} +
        • {{ next_node }}
        • + {% endif %} + {% endfor %} +
        +
        + {% else %} +
        + Trace completed +
        Total segments: {{ traced_path|length }}
        +
        Total length: + {% if total_length %} + {{ total_length|floatformat:"-2" }} Meters + {% else %} + N/A + {% endif %} +
        +
        + {% endif %} + {% endif %} + + {% endfor %} + {% endwith %}
        -
        - {% endfor %} -
        - {% if split_ends %} -
        -
        -
        - Trace Split -
        -
        - There are multiple possible paths from this point. Select a port to continue. -
        +
        + +
        +
        + Related Paths
        -
        - - - - - - - - - - {% for termination in split_ends %} - - +
        PortConnectedTypeDescription
        {{ termination }}
        + + + + + + + + + {% for cablepath in related_paths %} + + - - + + {% empty %} + {% endfor %} -
        OriginDestinationSegments
        - {% if termination.cable %} - + + {{ cablepath.origin.parent }} / {{ cablepath.origin }} + + + {% if cablepath.destination %} + {{ cablepath.destination }} ({{ cablepath.destination.parent }}) {% else %} - + Incomplete {% endif %} {{ termination.get_type_display }}{{ termination.description|placeholder }} + {{ cablepath.segment_count }} +
        + None found +
        -
        + +
        - {% else %} -
        -

        Trace completed!

        -
        - {% endif %} + +
        {% endblock %} diff --git a/netbox/templates/dcim/console_connections_list.html b/netbox/templates/dcim/connections_list.html similarity index 60% rename from netbox/templates/dcim/console_connections_list.html rename to netbox/templates/dcim/connections_list.html index 15f7a36bb..80d76e7d1 100644 --- a/netbox/templates/dcim/console_connections_list.html +++ b/netbox/templates/dcim/connections_list.html @@ -5,14 +5,14 @@
        {% export_button content_type %}
        -

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

        +

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

        -
        +
        +
        + {% include 'inc/search_panel.html' %} +
        {% include 'responsive_table.html' %} {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
        -
        - {% include 'inc/search_panel.html' %} -
        {% endblock %} diff --git a/netbox/templates/dcim/consoleport.html b/netbox/templates/dcim/consoleport.html new file mode 100644 index 000000000..5d113c86a --- /dev/null +++ b/netbox/templates/dcim/consoleport.html @@ -0,0 +1,115 @@ +{% extends 'dcim/device_component.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
        +
        +
        +
        + Console Port +
        + + + + + + + + + + + + + + + + + + + + + +
        Device + {{ object.device }} +
        Name{{ object.name }}
        Label{{ object.label|placeholder }}
        Type{{ object.get_type_display }}
        Description{{ object.description|placeholder }}
        +
        + {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} + {% plugin_left_page object %} +
        +
        +
        +
        + Connection +
        + {% if object.cable %} + + + + + + {% if object.connected_endpoint %} + + + + + + + + + + + + + + + + + + + + + {% endif %} +
        Cable + {{ object.cable }} + + + +
        Device + {{ object.connected_endpoint.device }} +
        Name + {{ object.connected_endpoint.name }} +
        Type{{ object.connected_endpoint.get_type_display|placeholder }}
        Description{{ object.connected_endpoint.description|placeholder }}
        Path Status + {% if object.path.is_active %} + Reachable + {% else %} + Not Reachable + {% endif %} +
        + {% else %} +
        + Not connected + {% if perms.dcim.add_cable %} + + + + + {% endif %} +
        + {% endif %} +
        + {% plugin_right_page object %} +
        +
        +
        +
        + {% plugin_full_width_page object %} +
        +
        +{% endblock %} diff --git a/netbox/templates/dcim/consoleserverport.html b/netbox/templates/dcim/consoleserverport.html new file mode 100644 index 000000000..b64b4aff2 --- /dev/null +++ b/netbox/templates/dcim/consoleserverport.html @@ -0,0 +1,115 @@ +{% extends 'dcim/device_component.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
        +
        +
        +
        + Console Server Port +
        + + + + + + + + + + + + + + + + + + + + + +
        Device + {{ object.device }} +
        Name{{ object.name }}
        Label{{ object.label|placeholder }}
        Type{{ object.get_type_display }}
        Description{{ object.description|placeholder }}
        +
        + {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} + {% plugin_left_page object %} +
        +
        +
        +
        + Connection +
        + {% if object.cable %} + + + + + + {% if object.connected_endpoint %} + + + + + + + + + + + + + + + + + + + + + {% endif %} +
        Cable + {{ object.cable }} + + + +
        Device + {{ object.connected_endpoint.device }} +
        Name + {{ object.connected_endpoint.name }} +
        Type{{ object.connected_endpoint.get_type_display|placeholder }}
        Description{{ object.connected_endpoint.description|placeholder }}
        Path Status + {% if object.path.is_active %} + Reachable + {% else %} + Not Reachable + {% endif %} +
        + {% else %} +
        + Not connected + {% if perms.dcim.add_cable %} + + + + + {% endif %} +
        + {% endif %} +
        + {% plugin_right_page object %} +
        +
        +
        +
        + {% plugin_full_width_page object %} +
        +
        +{% endblock %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index ef1a301e2..97f7c8953 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -1,931 +1,345 @@ -{% extends 'base.html' %} +{% extends 'dcim/device/base.html' %} +{% load render_table from django_tables2 %} {% load buttons %} {% load static %} {% load helpers %} -{% load custom_links %} {% load plugins %} -{% block title %}{{ device }}{% endblock %} - -{% block header %} -
        -
        - -
        -
        -
        -
        - - - - -
        -
        -
        -
        -
        - {% plugin_buttons device %} - {% if show_graphs %} - - {% endif %} - {% if perms.dcim.change_device %} -
        - - -
        - {% endif %} - {% if perms.dcim.add_device %} - {% clone_button device %} - {% endif %} - {% if perms.dcim.change_device %} - {% edit_button device %} - {% endif %} - {% if perms.dcim.delete_device %} - {% delete_button device %} - {% endif %} -
        -

        {{ device }}

        - {% include 'inc/created_updated.html' with obj=device %} -
        - {% custom_links device %} -
        - -{% endblock %} - {% block content %}
        -
        -
        -
        - Device -
        - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
        Site - {% if device.site.region %} - {{ device.site.region }} - - {% endif %} - {{ device.site }} -
        Rack - {% if device.rack %} - {% if device.rack.group %} - {{ device.rack.group }} - - {% endif %} - {{ device.rack }} - {% else %} - None - {% endif %} -
        Position - {% if device.parent_bay %} - {% with device.parent_bay.device as parent %} - {{ parent }} {{ device.parent_bay }} - {% if parent.position %} - (U{{ parent.position }} / {{ parent.get_face_display }}) - {% endif %} - {% endwith %} - {% elif device.rack and device.position %} - U{{ device.position }} / {{ device.get_face_display }} - {% elif device.rack and device.device_type.u_height %} - Not racked - {% else %} - - {% endif %} -
        Tenant - {% if device.tenant %} - {% if device.tenant.group %} - {{ device.tenant.group }} - - {% endif %} - {{ device.tenant }} - {% else %} - None - {% endif %} -
        Device Type - {{ device.device_type.display_name }} ({{ device.device_type.u_height }}U) -
        Serial Number{{ device.serial|placeholder }}
        Asset Tag{{ device.asset_tag|placeholder }}
        -
        - {% if vc_members %} -
        -
        - Virtual Chassis -
        - - - - - - - - {% for vc_member in vc_members %} - - - - - - - {% endfor %} -
        DevicePositionMasterPriority
        - {{ vc_member }} - {{ vc_member.vc_position }}{% if device.virtual_chassis.master == vc_member %}{% endif %}{{ vc_member.vc_priority|default:"" }}
        - -
        - {% endif %} -
        -
        - Management -
        - - - - - - - - - - - - - - - - - - - - - - {% if device.cluster %} - - - - - {% endif %} -
        Role - {{ device.device_role }} -
        Platform - {% if device.platform %} - {{ device.platform }} - {% else %} - None - {% endif %} -
        Status - {{ device.get_status_display }} -
        Primary IPv4 - {% if device.primary_ip4 %} - {{ device.primary_ip4.address.ip }} - {% if device.primary_ip4.nat_inside %} - (NAT for {{ device.primary_ip4.nat_inside.address.ip }}) - {% elif device.primary_ip4.nat_outside %} - (NAT: {{ device.primary_ip4.nat_outside.address.ip }}) - {% endif %} - {% else %} - - {% endif %} -
        Primary IPv6 - {% if device.primary_ip6 %} - {{ device.primary_ip6.address.ip }} - {% if device.primary_ip6.nat_inside %} - (NAT for {{ device.primary_ip6.nat_inside.address.ip }}) - {% elif device.primary_ip6.nat_outside %} - (NAT: {{ device.primary_ip6.nat_outside.address.ip }}) - {% endif %} - {% else %} - - {% endif %} -
        Cluster - {% if device.cluster.group %} - {{ device.cluster.group }} - - {% endif %} - {{ device.cluster }} -
        -
        - {% include 'inc/custom_fields_panel.html' with obj=device %} - {% include 'extras/inc/tags_panel.html' with tags=device.tags.all url='dcim:device_list' %} -
        -
        - Comments -
        -
        - {% if device.comments %} - {{ device.comments|render_markdown }} - {% else %} - None - {% endif %} -
        -
        - {% plugin_left_page device %} -
        -
        - {% if console_ports or power_ports %} -
        -
        - Console / Power -
        - - {% for cp in console_ports %} - {% include 'dcim/inc/consoleport.html' %} - {% endfor %} - {% for pp in power_ports %} - {% include 'dcim/inc/powerport.html' %} - {% endfor %} -
        - {% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %} - - {% endif %} -
        - {% endif %} - {% if power_ports and poweroutlets %} -
        -
        - Power Utilization -
        - - - - - - - - - {% for pp in power_ports %} - {% with utilization=pp.get_power_draw powerfeed=pp.connected_endpoint %} - - - - - {% if powerfeed.available_power %} - - - {% else %} - - - {% endif %} - - {% for leg in utilization.legs %} +
        +
        +
        +
        +
        +
        +
        + Device +
        +
        InputOutletsAllocatedAvailableUtilization
        {{ pp }}{{ utilization.outlet_count }}{{ utilization.allocated }}VA{{ powerfeed.available_power }}VA{% utilization_graph utilization.allocated|percentage:powerfeed.available_power %}
        - - - - - {% with phase_available=powerfeed.available_power|divide:3 %} - - {% endwith %} + + - {% endfor %} - {% endwith %} - {% endfor %} -
        Leg {{ leg.name }}{{ leg.outlet_count }}{{ leg.allocated }}{{ powerfeed.available_power|divide:3 }}VA{% utilization_graph leg.allocated|percentage:phase_available %}Site + {% if object.site.region %} + {{ object.site.region }} / + {% endif %} + {{ object.site }} +
        -
        - {% endif %} - {% if request.user.is_authenticated %} -
        -
        - Secrets -
        - {% if secrets %} - - {% for secret in secrets %} - {% include 'secrets/inc/secret_tr.html' %} - {% endfor %} -
        - {% else %} -
        - None found -
        - {% endif %} - {% if perms.secrets.add_secret %} -
        - {% csrf_token %} -
        - - {% endif %} -
        - {% endif %} -
        -
        - Services -
        - {% if services %} - - {% for service in services %} - {% include 'ipam/inc/service.html' %} - {% endfor %} -
        - {% else %} -
        - None -
        - {% endif %} - {% if perms.ipam.add_service %} - - {% endif %} -
        -
        -
        - Images -
        - {% include 'inc/image_attachments.html' with images=device.images.all %} - {% if perms.extras.add_imageattachment %} - - {% endif %} -
        -
        -
        - Related Devices -
        - {% if related_devices %} - - {% for rd in related_devices %} - - - + + + + + + + + + + + + + + + + + + + + + + + +
        - {{ rd }} - - {% if rd.rack %} - Rack {{ rd.rack }} +
        Rack + {% if object.rack %} + {% if object.rack.group %} + {{ object.rack.group }} / + {% endif %} + {{ object.rack }} + {% else %} + None + {% endif %} +
        Position + {% if object.parent_bay %} + {% with object.parent_bay.device as parent %} + {{ parent }} / {{ object.parent_bay }} + {% if parent.position %} + (U{{ parent.position }} / {{ parent.get_face_display }}) + {% endif %} + {% endwith %} + {% elif object.rack and object.position %} + U{{ object.position }} / {{ object.get_face_display }} + {% elif object.rack and object.device_type.u_height %} + Not racked + {% else %} + + {% endif %} +
        Tenant + {% if object.tenant %} + {% if object.tenant.group %} + {{ object.tenant.group }} / + {% endif %} + {{ object.tenant }} + {% else %} + None + {% endif %} +
        Device Type + {{ object.device_type.display_name }} ({{ object.device_type.u_height }}U) +
        Serial Number{{ object.serial|placeholder }}
        Asset Tag{{ object.asset_tag|placeholder }}
        +
        + {% if vc_members %} +
        +
        + Virtual Chassis +
        + + + + + + + + {% for vc_member in vc_members %} + + + + + + + {% endfor %} +
        DevicePositionMasterPriority
        + {{ vc_member }} + {{ vc_member.vc_position }}{% if object.virtual_chassis.master == vc_member %}{% endif %}{{ vc_member.vc_priority|default:"" }}
        + +
        + {% endif %} +
        +
        + Management +
        + + + + + + + + + + + + + + + + + + + + + + {% if object.cluster %} + + + + + {% endif %} +
        Role + {{ object.device_role }} +
        Platform + {% if object.platform %} + {{ object.platform }} + {% else %} + None + {% endif %} +
        Status + {{ object.get_status_display }} +
        Primary IPv4 + {% if object.primary_ip4 %} + {{ object.primary_ip4.address.ip }} + {% if object.primary_ip4.nat_inside %} + (NAT for {{ object.primary_ip4.nat_inside.address.ip }}) + {% elif object.primary_ip4.nat_outside %} + (NAT: {{ object.primary_ip4.nat_outside.address.ip }}) + {% endif %} + {% else %} + + {% endif %} +
        Primary IPv6 + {% if object.primary_ip6 %} + {{ object.primary_ip6.address.ip }} + {% if object.primary_ip6.nat_inside %} + (NAT for {{ object.primary_ip6.nat_inside.address.ip }}) + {% elif object.primary_ip6.nat_outside %} + (NAT: {{ object.primary_ip6.nat_outside.address.ip }}) + {% endif %} + {% else %} + + {% endif %} +
        Cluster + {% if object.cluster.group %} + {{ object.cluster.group }} / + {% endif %} + {{ object.cluster }} +
        +
        + {% include 'inc/custom_fields_panel.html' %} + {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:device_list' %} +
        +
        + Comments +
        +
        + {% if object.comments %} + {{ object.comments|render_markdown }} {% else %} - + None {% endif %} - - {{ rd.device_type.display_name }} - - {% endfor %} - - {% else %} -
        None found
        - {% endif %} +
        +
        + {% plugin_left_page object %} +
        +
        + {% if power_ports and poweroutlets %} +
        +
        + Power Utilization +
        + + + + + + + + + {% for pp in power_ports %} + {% with utilization=pp.get_power_draw powerfeed=pp.connected_endpoint %} + + + + + {% if powerfeed.available_power %} + + + {% else %} + + + {% endif %} + + {% for leg in utilization.legs %} + + + + + + {% with phase_available=powerfeed.available_power|divide:3 %} + + {% endwith %} + + {% endfor %} + {% endwith %} + {% endfor %} +
        InputOutletsAllocatedAvailableUtilization
        {{ pp }}{{ utilization.outlet_count }}{{ utilization.allocated }}VA{{ powerfeed.available_power }}VA{% utilization_graph utilization.allocated|percentage:powerfeed.available_power %}
        Leg {{ leg.name }}{{ leg.outlet_count }}{{ leg.allocated }}{{ powerfeed.available_power|divide:3 }}VA{% utilization_graph leg.allocated|percentage:phase_available %}
        +
        + {% endif %} + {% if perms.secrets.view_secret %} +
        +
        + Secrets +
        + {% include 'secrets/inc/assigned_secrets.html' %} + {% if perms.secrets.add_secret %} + + {% endif %} +
        + {% endif %} +
        +
        + Services +
        + {% if services %} + + {% for service in services %} + {% include 'ipam/inc/service.html' %} + {% endfor %} +
        + {% else %} +
        + None +
        + {% endif %} + {% if perms.ipam.add_service %} + + {% endif %} +
        +
        +
        + Images +
        + {% include 'inc/image_attachments.html' with images=object.images.all %} + {% if perms.extras.add_imageattachment %} + + {% endif %} +
        +
        +
        + Related Devices +
        + {% if related_devices %} + + {% for rd in related_devices %} + + + + + + {% endfor %} +
        + {{ rd }} + + {% if rd.rack %} + Rack {{ rd.rack }} + {% else %} + + {% endif %} + {{ rd.device_type.display_name }}
        + {% else %} +
        None found
        + {% endif %} +
        + {% plugin_right_page object %} +
        +
        +
        +
        + {% plugin_full_width_page object %} +
        +
        +
        - {% plugin_right_page device %}
        -
        -
        - {% plugin_full_width_page device %} -
        -
        -
        -
        - {% if device_bays or device.device_type.is_parent_device %} - {% if perms.dcim.delete_devicebay %} -
        - {% csrf_token %} - {% endif %} -
        -
        - Device Bays -
        - - - - {% if perms.dcim.change_devicebay or perms.dcim.delete_devicebay %} - - {% endif %} - - - - - - - - - {% for devicebay in device_bays %} - {% include 'dcim/inc/devicebay.html' %} - {% empty %} - - - - {% endfor %} - -
        NameStatusDescriptionInstalled Device
        — No device bays defined —
        - -
        - {% if perms.dcim.delete_devicebay %} -
        - {% endif %} - {% endif %} - {% if interfaces %} - {% if perms.dcim.change_interface or perms.dcim.delete_interface %} -
        - {% csrf_token %} - - {% endif %} -
        -
        - Interfaces -
        - -
        -
        - -
        -
        - - - - {% if perms.dcim.change_interface or perms.dcim.delete_interface %} - - {% endif %} - - - - - - - - - - - - {% for iface in interfaces %} - {% include 'dcim/inc/interface.html' %} - {% endfor %} - -
        NameLAGDescriptionMTUModeCableConnection
        - -
        - {% if perms.dcim.delete_interface %} -
        - {% endif %} - {% endif %} - {% if consoleserverports %} - {% if perms.dcim.delete_consoleserverport %} -
        - {% csrf_token %} - - {% endif %} -
        -
        - Console Server Ports -
        - - - - {% if perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %} - - {% endif %} - - - - - - - - - - {% for csp in consoleserverports %} - {% include 'dcim/inc/consoleserverport.html' %} - {% endfor %} - -
        NameTypeDescriptionCableConnection
        - -
        - {% if perms.dcim.delete_consoleserverport %} -
        - {% endif %} - {% endif %} - {% if poweroutlets %} - {% if perms.dcim.delete_poweroutlet %} -
        - {% csrf_token %} - - {% endif %} -
        -
        - Power Outlets -
        - - - - {% if perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %} - - {% endif %} - - - - - - - - - - - {% for po in poweroutlets %} - {% include 'dcim/inc/poweroutlet.html' %} - {% endfor %} - -
        NameTypeInput/LegDescriptionCableConnection
        - -
        - {% if perms.dcim.delete_poweroutlet %} -
        - {% endif %} - {% endif %} - {% if front_ports %} -
        - {% csrf_token %} - -
        -
        - Front Ports -
        - - - - {% if perms.dcim.change_frontport or perms.dcim.delete_frontport %} - - {% endif %} - - - - - - - - - - - - {% for frontport in front_ports %} - {% include 'dcim/inc/frontport.html' %} - {% endfor %} - -
        NameTypeRear PortPositionDescriptionCableConnection
        - -
        -
        - {% endif %} - {% if rear_ports %} -
        - {% csrf_token %} - -
        -
        - Rear Ports -
        - - - - {% if perms.dcim.change_rearport or perms.dcim.delete_rearport %} - - {% endif %} - - - - - - - - - - - {% for rearport in rear_ports %} - {% include 'dcim/inc/rearport.html' %} - {% endfor %} - -
        NameTypePositionsDescriptionCableConnection
        - -
        -
        - {% endif %} -
        -
        -{% include 'inc/modal.html' with name='graphs' title='Graphs' %} -{% include 'secrets/inc/private_key_modal.html' %} + {% include 'secrets/inc/private_key_modal.html' %} {% endblock %} {% block javascript %} - - - - + {% endblock %} diff --git a/netbox/templates/dcim/device/base.html b/netbox/templates/dcim/device/base.html new file mode 100644 index 000000000..631abde89 --- /dev/null +++ b/netbox/templates/dcim/device/base.html @@ -0,0 +1,178 @@ +{% extends 'base.html' %} +{% load buttons %} +{% load static %} +{% load helpers %} +{% load custom_links %} +{% load plugins %} + +{% block title %}{{ object }}{% endblock %} + +{% block header %} +
        +
        + +
        +
        +
        +
        + + + + +
        +
        +
        +
        +
        + {% plugin_buttons object %} + {% if perms.dcim.change_device %} +
        + + +
        + {% endif %} + {% if perms.dcim.add_device %} + {% clone_button object %} + {% endif %} + {% if perms.dcim.change_device %} + {% edit_button object %} + {% endif %} + {% if perms.dcim.delete_device %} + {% delete_button object %} + {% endif %} +
        +

        {{ object }}

        + {% include 'inc/created_updated.html' %} +
        + {% custom_links object %} +
        + +{% endblock %} diff --git a/netbox/templates/dcim/device_config.html b/netbox/templates/dcim/device/config.html similarity index 92% rename from netbox/templates/dcim/device_config.html rename to netbox/templates/dcim/device/config.html index fa35f28aa..4b73a2577 100644 --- a/netbox/templates/dcim/device_config.html +++ b/netbox/templates/dcim/device/config.html @@ -1,7 +1,7 @@ -{% extends 'dcim/device.html' %} +{% extends 'dcim/device/base.html' %} {% load static %} -{% block title %}{{ device }} - Config{% endblock %} +{% block title %}{{ object }} - Config{% endblock %} {% block content %} {% include 'inc/ajax_loader.html' %} @@ -36,7 +36,7 @@ + +{% endblock %} diff --git a/netbox/templates/dcim/device/consoleserverports.html b/netbox/templates/dcim/device/consoleserverports.html new file mode 100644 index 000000000..61d3afd8b --- /dev/null +++ b/netbox/templates/dcim/device/consoleserverports.html @@ -0,0 +1,53 @@ +{% extends 'dcim/device/base.html' %} +{% load render_table from django_tables2 %} +{% load helpers %} +{% load static %} + +{% block content %} +
        + {% csrf_token %} +
        +
        + Console Server Ports +
        + {% if request.user.is_authenticated %} + + {% endif %} +
        +
        + {% render_table consoleserverport_table 'inc/table.html' %} + +
        +
        + {% table_config_form consoleserverport_table %} +{% endblock %} + +{% block javascript %} + + +{% endblock %} diff --git a/netbox/templates/dcim/device/devicebays.html b/netbox/templates/dcim/device/devicebays.html new file mode 100644 index 000000000..d23f307e7 --- /dev/null +++ b/netbox/templates/dcim/device/devicebays.html @@ -0,0 +1,49 @@ +{% extends 'dcim/device/base.html' %} +{% load render_table from django_tables2 %} +{% load helpers %} +{% load static %} + +{% block content %} +
        + {% csrf_token %} +
        +
        + Device Bays +
        + {% if request.user.is_authenticated %} + + {% endif %} +
        +
        + {% render_table devicebay_table 'inc/table.html' %} + +
        +
        + {% table_config_form devicebay_table %} +{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/netbox/templates/dcim/device/frontports.html b/netbox/templates/dcim/device/frontports.html new file mode 100644 index 000000000..7084f592e --- /dev/null +++ b/netbox/templates/dcim/device/frontports.html @@ -0,0 +1,53 @@ +{% extends 'dcim/device/base.html' %} +{% load render_table from django_tables2 %} +{% load helpers %} +{% load static %} + +{% block content %} +
        + {% csrf_token %} +
        +
        + Front Ports +
        + {% if request.user.is_authenticated %} + + {% endif %} +
        +
        + {% render_table frontport_table 'inc/table.html' %} + +
        +
        + {% table_config_form frontport_table %} +{% endblock %} + +{% block javascript %} + + +{% endblock %} diff --git a/netbox/templates/dcim/device/interfaces.html b/netbox/templates/dcim/device/interfaces.html new file mode 100644 index 000000000..34897a601 --- /dev/null +++ b/netbox/templates/dcim/device/interfaces.html @@ -0,0 +1,57 @@ +{% extends 'dcim/device/base.html' %} +{% load render_table from django_tables2 %} +{% load helpers %} +{% load static %} + +{% block content %} +
        + {% csrf_token %} +
        +
        + Interfaces +
        + {% if request.user.is_authenticated %} + + {% endif %} +
        +
        + +
        +
        + {% render_table interface_table 'inc/table.html' %} + +
        +
        + {% table_config_form interface_table %} +{% endblock %} + +{% block javascript %} + + + +{% endblock %} diff --git a/netbox/templates/dcim/device/inventory.html b/netbox/templates/dcim/device/inventory.html new file mode 100644 index 000000000..5e52667cb --- /dev/null +++ b/netbox/templates/dcim/device/inventory.html @@ -0,0 +1,49 @@ +{% extends 'dcim/device/base.html' %} +{% load render_table from django_tables2 %} +{% load helpers %} +{% load static %} + +{% block content %} +
        + {% csrf_token %} +
        +
        + Inventory Items +
        + {% if request.user.is_authenticated %} + + {% endif %} +
        +
        + {% render_table inventoryitem_table 'inc/table.html' %} + +
        +
        + {% table_config_form inventoryitem_table %} +{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/netbox/templates/dcim/device_lldp_neighbors.html b/netbox/templates/dcim/device/lldp_neighbors.html similarity index 84% rename from netbox/templates/dcim/device_lldp_neighbors.html rename to netbox/templates/dcim/device/lldp_neighbors.html index 221c17a52..a2baa6322 100644 --- a/netbox/templates/dcim/device_lldp_neighbors.html +++ b/netbox/templates/dcim/device/lldp_neighbors.html @@ -1,6 +1,6 @@ -{% extends 'dcim/device.html' %} +{% extends 'dcim/device/base.html' %} -{% block title %}{{ device }} - LLDP Neighbors{% endblock %} +{% block title %}{{ object }} - LLDP Neighbors{% endblock %} {% block content %} {% include 'inc/ajax_loader.html' %} @@ -23,7 +23,7 @@ {{ iface }} {% if iface.connected_endpoint.device %} - + {{ iface.connected_endpoint.device }} @@ -32,7 +32,7 @@ {% elif iface.connected_endpoint.circuit %} {% with circuit=iface.connected_endpoint.circuit %} - + {{ circuit.provider }} {{ circuit }} {% endwith %} @@ -52,7 +52,7 @@ + +{% endblock %} diff --git a/netbox/templates/dcim/device/powerports.html b/netbox/templates/dcim/device/powerports.html new file mode 100644 index 000000000..9b56b64a3 --- /dev/null +++ b/netbox/templates/dcim/device/powerports.html @@ -0,0 +1,53 @@ +{% extends 'dcim/device/base.html' %} +{% load render_table from django_tables2 %} +{% load helpers %} +{% load static %} + +{% block content %} +
        + {% csrf_token %} +
        +
        + Power Ports +
        + {% if request.user.is_authenticated %} + + {% endif %} +
        +
        + {% render_table powerport_table 'inc/table.html' %} + +
        +
        + {% table_config_form powerport_table %} +{% endblock %} + +{% block javascript %} + + +{% endblock %} diff --git a/netbox/templates/dcim/device/rearports.html b/netbox/templates/dcim/device/rearports.html new file mode 100644 index 000000000..eeef667c2 --- /dev/null +++ b/netbox/templates/dcim/device/rearports.html @@ -0,0 +1,53 @@ +{% extends 'dcim/device/base.html' %} +{% load render_table from django_tables2 %} +{% load static %} +{% load helpers %} + +{% block content %} +
        + {% csrf_token %} +
        +
        + Rear Ports +
        + {% if request.user.is_authenticated %} + + {% endif %} +
        +
        + {% render_table rearport_table 'inc/table.html' %} + +
        +
        + {% table_config_form rearport_table %} +{% endblock %} + +{% block javascript %} + + +{% endblock %} diff --git a/netbox/templates/dcim/device_status.html b/netbox/templates/dcim/device/status.html similarity index 86% rename from netbox/templates/dcim/device_status.html rename to netbox/templates/dcim/device/status.html index ffb61286e..b7f8d0eaa 100644 --- a/netbox/templates/dcim/device_status.html +++ b/netbox/templates/dcim/device/status.html @@ -1,4 +1,4 @@ -{% extends 'dcim/device.html' %} +{% extends 'dcim/device/base.html' %} {% load static %} {% block title %}{{ device }} - Status{% endblock %} @@ -46,19 +46,19 @@
        Environment
        - + - + - + - + - +
        CPU CPU
        Memory Memory
        Temperature Temperature
        Fans Fans
        Power Power
        @@ -70,7 +70,7 @@ {% endblock %} diff --git a/netbox/templates/dcim/devicetype_edit.html b/netbox/templates/dcim/devicetype_edit.html index f4f363b14..5aba04b39 100644 --- a/netbox/templates/dcim/devicetype_edit.html +++ b/netbox/templates/dcim/devicetype_edit.html @@ -1,4 +1,4 @@ -{% extends 'utilities/obj_edit.html' %} +{% extends 'generic/object_edit.html' %} {% load form_helpers %} {% block form %} diff --git a/netbox/templates/dcim/frontport.html b/netbox/templates/dcim/frontport.html new file mode 100644 index 000000000..9dc0bc9f5 --- /dev/null +++ b/netbox/templates/dcim/frontport.html @@ -0,0 +1,106 @@ +{% extends 'dcim/device_component.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
        +
        +
        +
        + Front Port +
        + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
        Device + {{ object.device }} +
        Name{{ object.name }}
        Label{{ object.label|placeholder }}
        Type{{ object.get_type_display }}
        Rear Port + {{ object.rear_port }} +
        Rear Port Position{{ object.rear_port_position }}
        Description{{ object.description|placeholder }}
        +
        + {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} + {% plugin_left_page object %} +
        +
        +
        +
        + Connection +
        + {% if object.cable %} + + + + + + + + + +
        Cable + {{ object.cable }} + + + +
        Connection Status + {% if object.cable.status %} + {{ object.cable.get_status_display }} + {% else %} + {{ object.cable.get_status_display }} + {% endif %} +
        + {% else %} +
        + Not connected + {% if perms.dcim.add_cable %} + + + + + {% endif %} +
        + {% endif %} +
        + {% plugin_right_page object %} +
        +
        +
        +
        + {% plugin_full_width_page object %} +
        +
        +{% endblock %} diff --git a/netbox/templates/dcim/inc/cable_form.html b/netbox/templates/dcim/inc/cable_form.html index a52cc302e..c0ade9aec 100644 --- a/netbox/templates/dcim/inc/cable_form.html +++ b/netbox/templates/dcim/inc/cable_form.html @@ -29,5 +29,14 @@ {% endif %}
        + {% render_field form.tags %}
        +{% if form.custom_fields %} +
        +
        Custom Fields
        +
        + {% render_custom_fields form %} +
        +
        +{% endif %} diff --git a/netbox/templates/dcim/inc/cable_termination.html b/netbox/templates/dcim/inc/cable_termination.html index 0711ff121..1ba3d05c9 100644 --- a/netbox/templates/dcim/inc/cable_termination.html +++ b/netbox/templates/dcim/inc/cable_termination.html @@ -16,7 +16,9 @@ Component - {{ termination }} + + {{ termination }} + {% else %} {# Circuit termination #} diff --git a/netbox/templates/dcim/inc/cable_toggle_buttons.html b/netbox/templates/dcim/inc/cable_toggle_buttons.html index 507aab3be..98e4efd94 100644 --- a/netbox/templates/dcim/inc/cable_toggle_buttons.html +++ b/netbox/templates/dcim/inc/cable_toggle_buttons.html @@ -1,16 +1,16 @@ {% if perms.dcim.change_cable %} {% if cable.status == 'connected' %} - + {% else %} - + {% endif %} {% endif %} {% if perms.dcim.delete_cable %} - - + + {% endif %} diff --git a/netbox/templates/dcim/inc/cable_trace_end.html b/netbox/templates/dcim/inc/cable_trace_end.html deleted file mode 100644 index 6073c06ee..000000000 --- a/netbox/templates/dcim/inc/cable_trace_end.html +++ /dev/null @@ -1,34 +0,0 @@ -{% load helpers %} - -
        -
        - {% if end.device %} - {{ end.device }}
        - - {{ end.device.site }} - {% if end.device.rack %} - / {{ end.device.rack }} - {% endif %} - - {% else %} - {{ end.circuit.provider }} - {% endif %} -
        -
        - {% if end.device %} - {# Device component #} - {% with model=end|meta:"verbose_name" %} - {{ model|bettertitle }} {{ end }}
        - {% if model == 'interface' %} - {{ end.get_type_display }} - {% elif model == 'front port' or model == 'rear port' %} - {{ end.get_type_display }} - {% endif %} - {% endwith %} - {% else %} - {# Circuit termination #} - {{ end.circuit }}
        - {{ end }} - {% endif %} -
        -
        diff --git a/netbox/templates/dcim/inc/cabletermination.html b/netbox/templates/dcim/inc/cabletermination.html new file mode 100644 index 000000000..1962248e7 --- /dev/null +++ b/netbox/templates/dcim/inc/cabletermination.html @@ -0,0 +1,14 @@ + + {% if termination.parent.provider %} + + + {{ termination.parent.provider }} + {{ termination.parent }} + + {% else %} + {{ termination.parent }} + {% endif %} + + + {{ termination }} + diff --git a/netbox/templates/dcim/inc/consoleport.html b/netbox/templates/dcim/inc/consoleport.html deleted file mode 100644 index 9089f19b4..000000000 --- a/netbox/templates/dcim/inc/consoleport.html +++ /dev/null @@ -1,79 +0,0 @@ - - - {# Name #} - - {{ cp }} - - - {# Type #} - - {% if cp.type %}{{ cp.get_type_display }}{% else %}—{% endif %} - - - - - {# Description #} - - {{ cp.description }} - - - {# Cable #} - - {% if cp.cable %} - {{ cp.cable }} - - - - {% else %} - — - {% endif %} - - - {# Connection #} - {% if cp.connected_endpoint %} - - {{ cp.connected_endpoint.device }} - - - {{ cp.connected_endpoint }} - - {% else %} - - Not connected - - {% endif %} - - {# Actions #} - - {% if cp.cable %} - {% include 'dcim/inc/cable_toggle_buttons.html' with cable=cp.cable %} - {% elif perms.dcim.add_cable %} - - - - - {% endif %} - {% if perms.dcim.change_consoleport %} - - - - {% endif %} - {% if perms.dcim.delete_consoleport %} - {% if cp.connected_endpoint %} - - {% else %} - - - - {% endif %} - {% endif %} - - diff --git a/netbox/templates/dcim/inc/consoleserverport.html b/netbox/templates/dcim/inc/consoleserverport.html deleted file mode 100644 index 0d649f812..000000000 --- a/netbox/templates/dcim/inc/consoleserverport.html +++ /dev/null @@ -1,86 +0,0 @@ -{% load helpers %} - - - - {# Checkbox #} - {% if perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %} - - - - {% endif %} - - {# Name #} - - {{ csp }} - - - {# Type #} - - {% if csp.type %}{{ csp.get_type_display }}{% else %}—{% endif %} - - - {# Description #} - - {{ csp.description|placeholder }} - - - {# Cable #} - - {% if csp.cable %} - {{ csp.cable }} - - - - {% else %} - - {% endif %} - - - {# Connection #} - {% if csp.connected_endpoint %} - - {{ csp.connected_endpoint.device }} - - - {{ csp.connected_endpoint }} - - {% else %} - - Not connected - - {% endif %} - - {# Actions #} - - {% if csp.cable %} - {% include 'dcim/inc/cable_toggle_buttons.html' with cable=csp.cable %} - {% elif perms.dcim.add_cable %} - - - - - {% endif %} - {% if perms.dcim.change_consoleserverport %} - - - - {% endif %} - {% if perms.dcim.delete_consoleserverport %} - {% if csp.connected_endpoint %} - - {% else %} - - - - {% endif %} - {% endif %} - - diff --git a/netbox/templates/dcim/inc/device_component_table.html b/netbox/templates/dcim/inc/device_component_table.html new file mode 100644 index 000000000..41ca541ee --- /dev/null +++ b/netbox/templates/dcim/inc/device_component_table.html @@ -0,0 +1,40 @@ +{% load helpers %} +{% load perms %} +
        + {% csrf_token %} +
        +
        + {{ title }} +
        + + {% for obj in components %} + {% include component_template %} + {% endfor %} +
        + +
        +
        diff --git a/netbox/templates/dcim/inc/device_napalm_tabs.html b/netbox/templates/dcim/inc/device_napalm_tabs.html index 073f2fb9b..f402d94ef 100644 --- a/netbox/templates/dcim/inc/device_napalm_tabs.html +++ b/netbox/templates/dcim/inc/device_napalm_tabs.html @@ -1,12 +1,12 @@ {% if not disabled_message %} {% else %} diff --git a/netbox/templates/dcim/inc/devicebay.html b/netbox/templates/dcim/inc/devicebay.html deleted file mode 100644 index 70ce7e8df..000000000 --- a/netbox/templates/dcim/inc/devicebay.html +++ /dev/null @@ -1,70 +0,0 @@ -{% load helpers %} - - - {% if perms.dcim.change_devicebay or perms.dcim.delete_devicebay %} - - - - {% endif %} - - {# Name #} - - {{ devicebay.name }} - - - {# Status #} - - {% if devicebay.installed_device %} - - {{ devicebay.installed_device.get_status_display }} - - {% else %} - Vacant - {% endif %} - - - {# Description #} - - {{ devicebay.description|placeholder }} - - - {# Installed device #} - {% if devicebay.installed_device %} - - {{ devicebay.installed_device }} - - - {{ devicebay.installed_device.device_type.display_name }} - - {% else %} - - {% endif %} - - - {% if perms.dcim.change_devicebay %} - {% if devicebay.installed_device %} - - - - {% else %} - - - - {% endif %} - - - - {% endif %} - {% if perms.dcim.delete_devicebay %} - {% if devicebay.installed_device %} - - {% else %} - - - - {% endif %} - {% endif %} - - diff --git a/netbox/templates/dcim/inc/devicetype_component_table.html b/netbox/templates/dcim/inc/devicetype_component_table.html index a83059980..c0a2ff22a 100644 --- a/netbox/templates/dcim/inc/devicetype_component_table.html +++ b/netbox/templates/dcim/inc/devicetype_component_table.html @@ -1,3 +1,4 @@ +{% load helpers %} {% if perms.dcim.change_devicetype %}
        {% csrf_token %} @@ -8,20 +9,19 @@ {% include 'responsive_table.html' %} @@ -143,13 +135,13 @@ Physical Address - {% if site.physical_address %} + {% if object.physical_address %} - {{ site.physical_address|linebreaksbr }} + {{ object.physical_address|linebreaksbr }} {% else %} {% endif %} @@ -157,18 +149,18 @@ Shipping Address - {{ site.shipping_address|linebreaksbr|placeholder }} + {{ object.shipping_address|linebreaksbr|placeholder }} GPS Coordinates - {% if site.latitude and site.longitude %} + {% if object.latitude and object.longitude %} - {{ site.latitude }}, {{ site.longitude }} + {{ object.latitude }}, {{ object.longitude }} {% else %} {% endif %} @@ -176,13 +168,13 @@ Contact Name - {{ site.contact_name|placeholder }} + {{ object.contact_name|placeholder }} Contact Phone - {% if site.contact_phone %} - {{ site.contact_phone }} + {% if object.contact_phone %} + {{ object.contact_phone }} {% else %} {% endif %} @@ -191,8 +183,8 @@ Contact E-Mail - {% if site.contact_email %} - {{ site.contact_email }} + {% if object.contact_email %} + {{ object.contact_email }} {% else %} {% endif %} @@ -200,21 +192,21 @@ - {% include 'inc/custom_fields_panel.html' with obj=site %} - {% include 'extras/inc/tags_panel.html' with tags=site.tags.all url='dcim:site_list' %} + {% include 'inc/custom_fields_panel.html' %} + {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:site_list' %}
        Comments
        - {% if site.comments %} - {{ site.comments|render_markdown }} + {% if object.comments %} + {{ object.comments|render_markdown }} {% else %} None {% endif %}
        - {% plugin_left_page site %} + {% plugin_left_page object %}
        @@ -223,27 +215,27 @@
        @@ -255,21 +247,21 @@ {% for rg in rack_groups %} - + {% endfor %} - + @@ -279,27 +271,22 @@
        Images
        - {% include 'inc/image_attachments.html' with images=site.images.all %} + {% include 'inc/image_attachments.html' with images=object.images.all %} {% if perms.extras.add_imageattachment %} {% endif %} - {% plugin_right_page site %} + {% plugin_right_page object %} -{% include 'inc/modal.html' with name='graphs' title='Graphs' %}
        - {% plugin_full_width_page site %} + {% plugin_full_width_page object %}
        {% endblock %} - -{% block javascript %} - -{% endblock %} diff --git a/netbox/templates/dcim/site_edit.html b/netbox/templates/dcim/site_edit.html index 5819b964f..274863fc4 100644 --- a/netbox/templates/dcim/site_edit.html +++ b/netbox/templates/dcim/site_edit.html @@ -1,4 +1,4 @@ -{% extends 'utilities/obj_edit.html' %} +{% extends 'generic/object_edit.html' %} {% load form_helpers %} {% block form %} diff --git a/netbox/templates/dcim/trace/cable.html b/netbox/templates/dcim/trace/cable.html new file mode 100644 index 000000000..4235768a6 --- /dev/null +++ b/netbox/templates/dcim/trace/cable.html @@ -0,0 +1,19 @@ +{% load helpers %} + +
        + + + {% if cable.label %}{{ cable.label }}{% else %}Cable #{{ cable.pk }}{% endif %} + +
        + {% if cable.type %} + {{ cable.get_type_display|default:"" }}
        + {% endif %} + {% if cable.length %} + ({{ cable.length }} {{ cable.get_length_unit_display }})
        + {% endif %} + {{ cable.get_status_display }}
        + {% for tag in cable.tags.all %} + {% tag tag 'dcim:cable_list' %} + {% endfor %} +
        diff --git a/netbox/templates/dcim/trace/circuit.html b/netbox/templates/dcim/trace/circuit.html new file mode 100644 index 000000000..70e191dd2 --- /dev/null +++ b/netbox/templates/dcim/trace/circuit.html @@ -0,0 +1,5 @@ + diff --git a/netbox/templates/dcim/trace/device.html b/netbox/templates/dcim/trace/device.html new file mode 100644 index 000000000..c3696e786 --- /dev/null +++ b/netbox/templates/dcim/trace/device.html @@ -0,0 +1,8 @@ +
        + {{ device }}
        + {{ device.device_type.manufacturer }} {{ device.device_type }}
        + {{ device.site }} + {% if device.rack %} + / {{ device.rack }} + {% endif %} +
        diff --git a/netbox/templates/dcim/trace/powerpanel.html b/netbox/templates/dcim/trace/powerpanel.html new file mode 100644 index 000000000..f5b6230a7 --- /dev/null +++ b/netbox/templates/dcim/trace/powerpanel.html @@ -0,0 +1,5 @@ + diff --git a/netbox/templates/dcim/trace/termination.html b/netbox/templates/dcim/trace/termination.html new file mode 100644 index 000000000..e85bded05 --- /dev/null +++ b/netbox/templates/dcim/trace/termination.html @@ -0,0 +1,8 @@ +{% load helpers %} +
        + {{ termination }}
        + {{ termination|meta:"verbose_name"|bettertitle }} + {% if termination.type %} + ({{ termination.get_type_display }}) + {% endif %} +
        diff --git a/netbox/templates/dcim/virtualchassis.html b/netbox/templates/dcim/virtualchassis.html index a97c42e4f..eb9972406 100644 --- a/netbox/templates/dcim/virtualchassis.html +++ b/netbox/templates/dcim/virtualchassis.html @@ -9,8 +9,10 @@
        @@ -19,7 +21,7 @@
        @@ -27,26 +29,26 @@
        - {% plugin_buttons virtualchassis %} + {% plugin_buttons object %} {% if perms.dcim.change_virtualchassis %} - {% edit_button virtualchassis %} + {% edit_button object %} {% endif %} {% if perms.dcim.delete_virtualchassis %} - {% delete_button virtualchassis %} + {% delete_button object %} {% endif %}
        -

        {% block title %}{{ virtualchassis }}{% endblock %}

        - {% include 'inc/created_updated.html' with obj=virtualchassis %} +

        {% block title %}{{ object }}{% endblock %}

        + {% include 'inc/created_updated.html' %}
        - {% custom_links virtualchassis %} + {% custom_links object %}
        @@ -62,12 +64,23 @@
        {{ rg }} {{ rg }} {{ rg.rack_count }} - +
        All racks All racks {{ stats.rack_count }} - - + +
        - - + + + + + +
        Domain{{ virtualchassis.domain|placeholder }}{{ object.domain|placeholder }}
        Master + {% if object.master %} + {{ object.master }} + {% else %} + + {% endif %} +
        - {% include 'extras/inc/tags_panel.html' with tags=virtualchassis.tags.all url='dcim:virtualchassis_list' %} - {% plugin_left_page virtualchassis %} + {% include 'inc/custom_fields_panel.html' %} + {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:virtualchassis_list' %} + {% plugin_left_page object %}
        @@ -81,31 +94,31 @@ Master Priority - {% for vc_member in virtualchassis.members.all %} + {% for vc_member in members %} {{ vc_member }} {{ vc_member.vc_position }} - {% if virtualchassis.master == vc_member %}{% endif %} + {% if object.master == vc_member %}{% endif %} {{ vc_member.vc_priority|placeholder }} {% endfor %} {% if perms.dcim.change_virtualchassis %} {% endif %}
        - {% plugin_right_page virtualchassis %} + {% plugin_right_page object %}
        - {% plugin_full_width_page virtualchassis %} + {% plugin_full_width_page object %}
        {% endblock %} diff --git a/netbox/templates/dcim/virtualchassis_add.html b/netbox/templates/dcim/virtualchassis_add.html new file mode 100644 index 000000000..2a68cb5e4 --- /dev/null +++ b/netbox/templates/dcim/virtualchassis_add.html @@ -0,0 +1,31 @@ +{% extends 'generic/object_edit.html' %} +{% load form_helpers %} + +{% block form %} +
        +
        Virtual Chassis
        +
        + {% render_field form.name %} + {% render_field form.domain %} + {% render_field form.tags %} +
        +
        +
        +
        Member Devices
        +
        + {% render_field form.region %} + {% render_field form.site %} + {% render_field form.rack %} + {% render_field form.members %} + {% render_field form.initial_position %} +
        +
        + {% if form.custom_fields %} +
        +
        Custom Fields
        +
        + {% render_custom_fields form %} +
        +
        + {% endif %} +{% endblock %} diff --git a/netbox/templates/dcim/virtualchassis_edit.html b/netbox/templates/dcim/virtualchassis_edit.html index 54bdc9fe8..6b3d800f1 100644 --- a/netbox/templates/dcim/virtualchassis_edit.html +++ b/netbox/templates/dcim/virtualchassis_edit.html @@ -21,9 +21,20 @@
        Virtual Chassis
        - {% render_form vc_form %} + {% render_field vc_form.name %} + {% render_field vc_form.domain %} + {% render_field vc_form.master %} + {% render_field vc_form.tags %}
        + {% if vc_form.custom_fields %} +
        +
        Custom Fields
        +
        + {% render_custom_fields vc_form %} +
        +
        + {% endif %}
        Members
        @@ -72,7 +83,7 @@ diff --git a/netbox/templates/exceptions/import_error.html b/netbox/templates/exceptions/import_error.html index 20dc3728b..776014e6f 100644 --- a/netbox/templates/exceptions/import_error.html +++ b/netbox/templates/exceptions/import_error.html @@ -5,14 +5,15 @@ A module import error occurred during this request. Common causes include the following:

        - Missing required packages - This installation of NetBox might be missing one or more required - Python packages. These packages are listed in requirements.txt and are normally installed as part - of the installation or upgrade process. To verify installed packages, run pip freeze from the - console and compare the output to the list of required packages. + Missing required packages - This installation of NetBox might be + missing one or more required Python packages. These packages are listed in requirements.txt and + local_requirements.txt, and are normally installed as part of the installation or upgrade process. + To verify installed packages, run pip freeze from the console and compare the output to the list of + required packages.

        - WSGI service not restarted after upgrade - If this installation has recently been upgraded, - check that the WSGI service (e.g. gunicorn or uWSGI) has been restarted. This ensures that the new code is - running. + WSGI service not restarted after upgrade - If this installation + has recently been upgraded, check that the WSGI service (e.g. gunicorn or uWSGI) has been restarted. This + ensures that the new code is running.

        {% endblock %} diff --git a/netbox/templates/exceptions/permission_error.html b/netbox/templates/exceptions/permission_error.html index 9898f25dc..d6fb3d18c 100644 --- a/netbox/templates/exceptions/permission_error.html +++ b/netbox/templates/exceptions/permission_error.html @@ -5,7 +5,7 @@ A file permission error was detected while processing this request. Common causes include the following:

        - Insufficient write permission to the media root - The configured + Insufficient write permission to the media root - The configured media root is {{ settings.MEDIA_ROOT }}. Ensure that the user NetBox runs as has access to write files to all locations within this path.

        diff --git a/netbox/templates/exceptions/programming_error.html b/netbox/templates/exceptions/programming_error.html index 48ab707b7..d55c311cc 100644 --- a/netbox/templates/exceptions/programming_error.html +++ b/netbox/templates/exceptions/programming_error.html @@ -5,12 +5,12 @@ A database programming error was detected while processing this request. Common causes include the following:

        - Database migrations missing - When upgrading to a new NetBox release, the upgrade script must + Database migrations missing - When upgrading to a new NetBox release, the upgrade script must be run to apply any new database migrations. You can run migrations manually by executing python3 manage.py migrate from the command line.

        - Unsupported PostgreSQL version - Ensure that PostgreSQL version 9.6 or higher is in use. You + Unsupported PostgreSQL version - Ensure that PostgreSQL version 9.6 or higher is in use. You can check this by connecting to the database using NetBox's credentials and issuing a query for SELECT VERSION().

        diff --git a/netbox/templates/extras/configcontext.html b/netbox/templates/extras/configcontext.html index 21e8cdab6..408e51b75 100644 --- a/netbox/templates/extras/configcontext.html +++ b/netbox/templates/extras/configcontext.html @@ -7,7 +7,7 @@
        @@ -16,7 +16,7 @@
        @@ -25,13 +25,23 @@
        {% if perms.extras.change_configcontext %} - - + + Edit this config context {% endif %}
        -

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

        + +

        {% block title %}{{ object }}{% endblock %}

        {% endblock %} {% block content %} @@ -45,29 +55,29 @@ - + @@ -82,9 +92,9 @@
        {% if virtual_chassis.pk %} - + {% endif %}
        Name - {{ configcontext.name }} + {{ object.name }}
        Weight - {{ configcontext.weight }} + {{ object.weight }}
        Description{{ configcontext.description|placeholder }}{{ object.description|placeholder }}
        Active - {% if configcontext.is_active %} + {% if object.is_active %} - + {% else %} - + {% endif %}
        Regions - {% if configcontext.regions.all %} + {% if object.regions.all %}
          - {% for region in configcontext.regions.all %} + {% for region in object.regions.all %}
        • {{ region }}
        • {% endfor %}
        @@ -96,9 +106,9 @@
        Sites - {% if configcontext.sites.all %} + {% if object.sites.all %}
          - {% for site in configcontext.sites.all %} + {% for site in object.sites.all %}
        • {{ site }}
        • {% endfor %}
        @@ -110,9 +120,9 @@
        Roles - {% if configcontext.roles.all %} + {% if object.roles.all %}
          - {% for role in configcontext.roles.all %} + {% for role in object.roles.all %}
        • {{ role }}
        • {% endfor %}
        @@ -124,9 +134,9 @@
        Platforms - {% if configcontext.platforms.all %} + {% if object.platforms.all %}
          - {% for platform in configcontext.platforms.all %} + {% for platform in object.platforms.all %}
        • {{ platform }}
        • {% endfor %}
        @@ -138,9 +148,9 @@
        Cluster Groups - {% if configcontext.cluster_groups.all %} + {% if object.cluster_groups.all %}
          - {% for cluster_group in configcontext.cluster_groups.all %} + {% for cluster_group in object.cluster_groups.all %}
        • {{ cluster_group }}
        • {% endfor %}
        @@ -152,9 +162,9 @@
        Clusters - {% if configcontext.clusters.all %} + {% if object.clusters.all %}
          - {% for cluster in configcontext.clusters.all %} + {% for cluster in object.clusters.all %}
        • {{ cluster }}
        • {% endfor %}
        @@ -166,9 +176,9 @@
        Tenant Groups - {% if configcontext.tenant_groups.all %} + {% if object.tenant_groups.all %}
          - {% for tenant_group in configcontext.tenant_groups.all %} + {% for tenant_group in object.tenant_groups.all %}
        • {{ tenant_group }}
        • {% endfor %}
        @@ -180,9 +190,9 @@
        Tenants - {% if configcontext.tenants.all %} + {% if object.tenants.all %}
          - {% for tenant in configcontext.tenants.all %} + {% for tenant in object.tenants.all %}
        • {{ tenant }}
        • {% endfor %}
        @@ -194,9 +204,9 @@
        Tags - {% if configcontext.tags.all %} + {% if object.tags.all %}
          - {% for tag in configcontext.tags.all %} + {% for tag in object.tags.all %}
        • {{ tag }}
        • {% endfor %}
        @@ -215,7 +225,7 @@ {% include 'extras/inc/configcontext_format.html' %}
        - {% include 'extras/inc/configcontext_data.html' with data=configcontext.data format=format %} + {% include 'extras/inc/configcontext_data.html' with data=object.data format=format %}
        diff --git a/netbox/templates/extras/configcontext_edit.html b/netbox/templates/extras/configcontext_edit.html index 9e922108c..08e92d258 100644 --- a/netbox/templates/extras/configcontext_edit.html +++ b/netbox/templates/extras/configcontext_edit.html @@ -1,4 +1,4 @@ -{% extends 'utilities/obj_edit.html' %} +{% extends 'generic/object_edit.html' %} {% load form_helpers %} {% block form %} diff --git a/netbox/templates/extras/inc/job_label.html b/netbox/templates/extras/inc/job_label.html new file mode 100644 index 000000000..cf31c7f75 --- /dev/null +++ b/netbox/templates/extras/inc/job_label.html @@ -0,0 +1,13 @@ +{% if result.status == 'failed' %} + +{% elif result.status == 'errored' %} + +{% elif result.status == 'pending' %} + +{% elif result.status == 'running' %} + +{% elif result.status == 'completed' %} + +{% else %} + +{% endif %} diff --git a/netbox/templates/extras/inc/report_label.html b/netbox/templates/extras/inc/report_label.html deleted file mode 100644 index d4d2c5919..000000000 --- a/netbox/templates/extras/inc/report_label.html +++ /dev/null @@ -1,7 +0,0 @@ -{% if result.failed %} - -{% elif result %} - -{% else %} - -{% endif %} diff --git a/netbox/templates/extras/inc/tags_panel.html b/netbox/templates/extras/inc/tags_panel.html index 257a1fc22..2024d4ab7 100644 --- a/netbox/templates/extras/inc/tags_panel.html +++ b/netbox/templates/extras/inc/tags_panel.html @@ -4,7 +4,7 @@ Tags
        - {% for tag in tags %} + {% for tag in tags.all %} {% tag tag url %} {% empty %} No tags assigned diff --git a/netbox/templates/extras/object_changelog.html b/netbox/templates/extras/object_changelog.html index 970b54d4d..64f76fe60 100644 --- a/netbox/templates/extras/object_changelog.html +++ b/netbox/templates/extras/object_changelog.html @@ -1,9 +1,8 @@ {% extends base_template %} -{% block title %}{% if obj %}{{ obj }}{% else %}{{ block.super }}{% endif %} - Change Log{% endblock %} +{% block title %}{{ block.super }} - Change Log{% endblock %} {% block content %} - {% if obj %}

        {{ obj }}

        {% endif %} {% include 'panel_table.html' %} {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
        diff --git a/netbox/templates/extras/object_configcontext.html b/netbox/templates/extras/object_configcontext.html index cc77def06..ca521f566 100644 --- a/netbox/templates/extras/object_configcontext.html +++ b/netbox/templates/extras/object_configcontext.html @@ -23,15 +23,15 @@ Local Context
        - {% if obj.local_context_data %} - {% include 'extras/inc/configcontext_data.html' with data=obj.local_context_data format=format %} + {% if object.local_context_data %} + {% include 'extras/inc/configcontext_data.html' with data=object.local_context_data format=format %} {% else %} None {% endif %}
        diff --git a/netbox/templates/extras/objectchange.html b/netbox/templates/extras/objectchange.html index dcaaafdca..15265889e 100644 --- a/netbox/templates/extras/objectchange.html +++ b/netbox/templates/extras/objectchange.html @@ -1,21 +1,21 @@ {% extends 'base.html' %} {% load helpers %} -{% block title %}{{ objectchange }}{% endblock %} +{% block title %}{{ object }}{% endblock %} {% block header %}
        @@ -24,7 +24,7 @@
        @@ -44,41 +44,41 @@
        Time - {{ objectchange.time }} + {{ object.time }}
        User - {{ objectchange.user|default:objectchange.user_name }} + {{ object.user|default:object.user_name }}
        Action - {{ objectchange.get_action_display }} + {{ object.get_action_display }}
        Object Type - {{ objectchange.changed_object_type }} + {{ object.changed_object_type }}
        Object - {% if objectchange.changed_object.get_absolute_url %} - {{ objectchange.changed_object }} + {% if object.changed_object.get_absolute_url %} + {{ object.changed_object }} {% else %} - {{ objectchange.object_repr }} + {{ object.object_repr }} {% endif %}
        Request ID - {{ objectchange.request_id }} + {{ object.request_id }}
        @@ -88,19 +88,19 @@ Difference
        {% if diff_added == diff_removed %} - {% if objectchange.action == 'create' %} + {% if object.action == 'create' %} Object created - {% elif objectchange.action == 'delete' %} + {% elif object.action == 'delete' %} Object deleted {% else %} No changes @@ -119,7 +119,7 @@ Object Data
        -
        {{ objectchange.object_data|render_json }}
        +
        {{ object.object_data|render_json }}
        @@ -129,7 +129,7 @@ {% include 'panel_table.html' with table=related_changes_table heading='Related Changes' panel_class='default' %} {% if related_changes_count > related_changes_table.rows|length %} {% endif %} diff --git a/netbox/templates/extras/objectchange_list.html b/netbox/templates/extras/objectchange_list.html index 3672f4f04..efcac976d 100644 --- a/netbox/templates/extras/objectchange_list.html +++ b/netbox/templates/extras/objectchange_list.html @@ -1,4 +1,4 @@ -{% extends 'utilities/obj_list.html' %} +{% extends 'generic/object_list.html' %} {% block title %}Change Log{% endblock %} diff --git a/netbox/templates/extras/report.html b/netbox/templates/extras/report.html index 8ddf74eca..76a34c060 100644 --- a/netbox/templates/extras/report.html +++ b/netbox/templates/extras/report.html @@ -3,7 +3,7 @@ {% block title %}{{ report.name }}{% endblock %} -{% block content %} +{% block header %}
        - {% if perms.extras.add_reportresult %} + {% if perms.extras.run_report %}
        - + {% csrf_token %} - {{ run_form }} - +
        {% endif %} -

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

        +

        {{ report.name }}

        + {% if report.description %} +

        {{ report.description }}

        + {% endif %} +{% endblock %} + +{% block content %}
        - {% if report.description %} -

        {{ report.description }}

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

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

        - {% endif %} - {% if report.result %} -
        -
        - Report Methods -
        - - {% for method, data in report.result.data.items %} - - - - - {% endfor %} -
        {{ method }} - - - - -
        -
        -
        -
        - Report Results -
        - - - - - - - - - - - {% for method, data in report.result.data.items %} - - - - {% for time, level, obj, url, message in data.log %} - - - - - - - {% endfor %} - {% endfor %} - -
        TimeLevelObjectMessage
        - {{ method }} -
        {{ time }} - - - {% if obj and url %} - {{ obj }} - {% elif obj %} - {{ obj }} - {% endif %} - {{ message }}
        -
        - {% else %} -
        No results are available for this report. Please run the report first.
        - {% endif %} -
        -
        {% if report.result %} + Last run: + {{ report.result.created }} + {% endif %}
        diff --git a/netbox/templates/extras/report_list.html b/netbox/templates/extras/report_list.html index 7de085974..7685cdacf 100644 --- a/netbox/templates/extras/report_list.html +++ b/netbox/templates/extras/report_list.html @@ -6,7 +6,7 @@
        {% if reports %} - {% for module, module_reports in reports %} + {% for module, module_reports in reports %}

        {{ module|bettertitle }}

        @@ -15,27 +15,48 @@ + {% for report in module_reports %} - - {% if report.result %} - - {% else %} - + + + {% for method, stats in report.result.data.items %} - @@ -79,13 +79,13 @@
        Status Description Last Run
        - {{ report.name }} + + {{ report.name }} + - {% include 'extras/inc/report_label.html' with result=report.result %} + {% include 'extras/inc/job_label.html' with result=report.result %} {{ report.description|default:"" }}{{ report.result.created }}Never{{ report.description|placeholder }} + {% if report.result %} + {{ report.result.created }} + {% else %} + Never + {% endif %} + + {% if perms.extras.run_report %} +
        +
        + {% csrf_token %} + +
        +
        {% endif %} +
        + {{ method }} @@ -66,10 +87,10 @@
          {% for report in module_reports %} - - {{ report.name }} + + {{ report.name }}
          - {% include 'extras/inc/report_label.html' with result=report.result %} + {% include 'extras/inc/job_label.html' with result=report.result %}
          {% endfor %} diff --git a/netbox/templates/extras/report_result.html b/netbox/templates/extras/report_result.html new file mode 100644 index 000000000..80715f2aa --- /dev/null +++ b/netbox/templates/extras/report_result.html @@ -0,0 +1,100 @@ +{% extends 'extras/report.html' %} +{% load helpers %} +{% load static %} + +{% block title %}{{ report.name }} - {{ result.get_status_display }}{% endblock %} + +{% block content %} +
          +
          +

          + Run: {{ result.created }} + {% if result.completed %} + Duration: {{ result.duration }} + {% else %} + + {% endif %} + {% include 'extras/inc/job_label.html' with result=result %} +

          + {% if result.completed %} +
          +
          + Report Methods +
          + + {% for method, data in result.data.items %} + + + + + {% endfor %} +
          {{ method }} + + + + +
          +
          +
          +
          + Report Results +
          + + + + + + + + + + + {% for method, data in result.data.items %} + + + + {% for time, level, obj, url, message in data.log %} + + + + + + + {% endfor %} + {% endfor %} + +
          TimeLevelObjectMessage
          + {{ method }} +
          {{ time }} + + + {% if obj and url %} + {{ obj }} + {% elif obj %} + {{ obj }} + {% endif %} + {{ message }}
          +
          + {% else %} +
          Pending results
          + {% endif %} +
          +
          +{% endblock %} + +{% block javascript %} + + +{% endblock %} diff --git a/netbox/templates/extras/script.html b/netbox/templates/extras/script.html index 01dc4bfa5..5808f707f 100644 --- a/netbox/templates/extras/script.html +++ b/netbox/templates/extras/script.html @@ -21,56 +21,17 @@ -
        • Source
        - {% if execution_time or script.log %} -
        -
        -
        -
        - Script Log -
        - - - - - - - {% for level, message in script.log %} - - - - - - {% empty %} - - - - {% endfor %} -
        LineLevelMessage
        {{ forloop.counter }}{% log_level level %}{{ message|render_markdown }}
        - No log output -
        - {% if execution_time %} - - {% endif %} -
        -
        -
        - {% endif %}
        {% if not perms.extras.run_script %}
        - + You do not have permission to run scripts.
        {% endif %} @@ -87,22 +48,19 @@
        {% else %}
        - + This script does not require any input to run.
        {% render_form form %} {% endif %}
        - + Cancel
        -
        -
        {{ output }}
        -

        {{ script.filename }}

        {{ script.source }}
        diff --git a/netbox/templates/extras/script_list.html b/netbox/templates/extras/script_list.html index a1b97cfc2..dccbba152 100644 --- a/netbox/templates/extras/script_list.html +++ b/netbox/templates/extras/script_list.html @@ -4,15 +4,17 @@ {% block content %}

        {% block title %}Scripts{% endblock %}

        -
        +
        {% if scripts %} {% for module, module_scripts in scripts.items %}

        {{ module|bettertitle }}

        - - + + + + @@ -21,7 +23,17 @@ + + {% if script.result %} + + {% else %} + + {% endif %} {% endfor %} @@ -34,5 +46,26 @@ {% endif %} +
        + {% if scripts %} +
        + {% for module, module_scripts in scripts.items %} +
        + {{ module|bettertitle }} +
        + + {% endfor %} +
        + {% endif %} +
        {% endblock %} diff --git a/netbox/templates/extras/script_result.html b/netbox/templates/extras/script_result.html new file mode 100644 index 000000000..c9fc348ee --- /dev/null +++ b/netbox/templates/extras/script_result.html @@ -0,0 +1,113 @@ +{% extends 'base.html' %} +{% load helpers %} +{% load form_helpers %} +{% load log_levels %} +{% load static %} + +{% block title %}{{ script }} - {{ result.get_status_display }}{% endblock %} + +{% block content %} +
        +
        + +
        +
        +

        {{ script }}

        +

        {{ script.Meta.description }}

        + +
        +

        + Run: {{ result.created }} + {% if result.completed %} + Duration: {{ result.duration }} + {% else %} + + {% endif %} + {% include 'extras/inc/job_label.html' with result=result %} +

        +
        + {% if result.completed %} +
        +
        +
        +
        + Script Log +
        +
        NameDescriptionNameStatusDescriptionLast Run
        {{ script }} + {% include 'extras/inc/job_label.html' with result=script.result %} + {{ script.Meta.description }} + {{ script.result.created }} + Never
        + + + + + + {% for log in result.data.log %} + + + + + + {% empty %} + + + + {% endfor %} +
        LineLevelMessage
        {{ forloop.counter }}{% log_level log.status %}{{ log.message|render_markdown }}
        + No log output +
        + {% if execution_time %} + + {% endif %} +
        +
        +
        + {% else %} +
        +
        +
        Pending results
        +
        +
        + {% endif %} +
        +
        +
        {{ result.data.output }}
        +
        +
        +

        {{ script.filename }}

        +
        {{ script.source }}
        +
        + +{% endblock %} + + +{% block javascript %} + + +{% endblock %} \ No newline at end of file diff --git a/netbox/templates/extras/tag.html b/netbox/templates/extras/tag.html index 0c20bcbdc..2ad7cf814 100644 --- a/netbox/templates/extras/tag.html +++ b/netbox/templates/extras/tag.html @@ -6,7 +6,7 @@
        @@ -15,7 +15,7 @@
        @@ -24,27 +24,27 @@
        {% if perms.taggit.change_tag %} - - + + Edit this tag {% endif %} {% if perms.taggit.delete_tag %} - - + + Delete this tag {% endif %}
        -

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

        - {% include 'inc/created_updated.html' with obj=tag %} +

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

        + {% include 'inc/created_updated.html' %} @@ -61,13 +61,13 @@
        Name - {{ tag.name }} + {{ object.name }}
        Slug - {{ tag.slug }} + {{ object.slug }}
        Color -   +  
        Description - {{ tag.description }} + {{ object.description|placeholder }}
        diff --git a/netbox/templates/extras/tag_edit.html b/netbox/templates/extras/tag_edit.html index 87b9a2e53..1516bde39 100644 --- a/netbox/templates/extras/tag_edit.html +++ b/netbox/templates/extras/tag_edit.html @@ -1,4 +1,4 @@ -{% extends 'utilities/obj_edit.html' %} +{% extends 'generic/object_edit.html' %} {% load form_helpers %} {% block form %} diff --git a/netbox/templates/utilities/obj_bulk_add_component.html b/netbox/templates/generic/object_bulk_add_component.html similarity index 100% rename from netbox/templates/utilities/obj_bulk_add_component.html rename to netbox/templates/generic/object_bulk_add_component.html diff --git a/netbox/templates/utilities/obj_bulk_delete.html b/netbox/templates/generic/object_bulk_delete.html similarity index 100% rename from netbox/templates/utilities/obj_bulk_delete.html rename to netbox/templates/generic/object_bulk_delete.html diff --git a/netbox/templates/utilities/obj_bulk_edit.html b/netbox/templates/generic/object_bulk_edit.html similarity index 100% rename from netbox/templates/utilities/obj_bulk_edit.html rename to netbox/templates/generic/object_bulk_edit.html diff --git a/netbox/templates/utilities/obj_bulk_import.html b/netbox/templates/generic/object_bulk_import.html similarity index 66% rename from netbox/templates/utilities/obj_bulk_import.html rename to netbox/templates/generic/object_bulk_import.html index 4359d49a6..170cf3665 100644 --- a/netbox/templates/utilities/obj_bulk_import.html +++ b/netbox/templates/generic/object_bulk_import.html @@ -53,7 +53,7 @@ {% if field.required %} - + {% else %} {% endif %} @@ -66,6 +66,27 @@ {% endif %} + {% if field.STATIC_CHOICES %} + + + {% endif %} {% if field.help_text %} {{ field.help_text }}
        {% elif field.label %} @@ -82,11 +103,11 @@

        - Required fields must be specified for all + Required fields must be specified for all objects.

        - Related objects may be referenced by any unique attribute. + Related objects may be referenced by any unique attribute. For example, vrf.rd would identify a VRF by its route distinguisher.

        {% endif %} diff --git a/netbox/templates/utilities/obj_bulk_remove.html b/netbox/templates/generic/object_bulk_remove.html similarity index 100% rename from netbox/templates/utilities/obj_bulk_remove.html rename to netbox/templates/generic/object_bulk_remove.html diff --git a/netbox/templates/dcim/bulk_rename.html b/netbox/templates/generic/object_bulk_rename.html similarity index 100% rename from netbox/templates/dcim/bulk_rename.html rename to netbox/templates/generic/object_bulk_rename.html diff --git a/netbox/templates/utilities/obj_delete.html b/netbox/templates/generic/object_delete.html similarity index 100% rename from netbox/templates/utilities/obj_delete.html rename to netbox/templates/generic/object_delete.html diff --git a/netbox/templates/utilities/obj_edit.html b/netbox/templates/generic/object_edit.html similarity index 88% rename from netbox/templates/utilities/obj_edit.html rename to netbox/templates/generic/object_edit.html index 5230b2594..965749a6d 100644 --- a/netbox/templates/utilities/obj_edit.html +++ b/netbox/templates/generic/object_edit.html @@ -13,7 +13,9 @@

        {% if settings.DOCS_ROOT %}
        - +
        {% endif %} {% block title %}{% if obj.pk %}Editing {{ obj_type }} {{ obj }}{% else %}Add a new {{ obj_type }}{% endif %}{% endblock %} @@ -31,7 +33,9 @@
        {{ obj_type|capfirst }}
        - {% render_form form %} + {% block form_fields %} + {% render_form form %} + {% endblock %}
        {% endblock %} diff --git a/netbox/templates/utilities/obj_import.html b/netbox/templates/generic/object_import.html similarity index 100% rename from netbox/templates/utilities/obj_import.html rename to netbox/templates/generic/object_import.html diff --git a/netbox/templates/utilities/obj_list.html b/netbox/templates/generic/object_list.html similarity index 74% rename from netbox/templates/utilities/obj_list.html rename to netbox/templates/generic/object_list.html index 85ff050ed..738cbca49 100644 --- a/netbox/templates/utilities/obj_list.html +++ b/netbox/templates/generic/object_list.html @@ -1,18 +1,19 @@ {% extends 'base.html' %} {% load buttons %} {% load helpers %} +{% load static %} {% block content %}
        {% block buttons %}{% endblock %} {% if request.user.is_authenticated and table_config_form %} - + {% endif %} {% if permissions.add and 'add' in action_buttons %} - {% add_button content_type.model_class|url_name:"add" %} + {% add_button content_type.model_class|validated_viewname:"add" %} {% endif %} {% if permissions.add and 'import' in action_buttons %} - {% import_button content_type.model_class|url_name:"import" %} + {% import_button content_type.model_class|validated_viewname:"import" %} {% endif %} {% if 'export' in action_buttons %} {% export_button content_type %} @@ -20,8 +21,14 @@

        {% block title %}{{ content_type.model_class|meta:"verbose_name_plural"|bettertitle }}{% endblock %}

        -
        - {% with bulk_edit_url=content_type.model_class|url_name:"bulk_edit" bulk_delete_url=content_type.model_class|url_name:"bulk_delete" %} +
        + {% if filter_form %} +
        + {% include 'inc/search_panel.html' %} + {% block sidebar %}{% endblock %} +
        + {% endif %} + {% with bulk_edit_url=content_type.model_class|validated_viewname:"bulk_edit" bulk_delete_url=content_type.model_class|validated_viewname:"bulk_delete" %} {% if permissions.change or permissions.delete %}
        {% csrf_token %} @@ -38,12 +45,12 @@
        {% if bulk_edit_url and permissions.change %} {% endif %} {% if bulk_delete_url and permissions.delete %} {% endif %}
        @@ -55,12 +62,12 @@ {% block bulk_buttons %}{% endblock %} {% if bulk_edit_url and permissions.change %} {% endif %} {% if bulk_delete_url and permissions.delete %} {% endif %}
        @@ -71,15 +78,11 @@ {% endwith %} {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
        - {% if table_config_form %} - {% include 'inc/table_config_form.html' %} - {% endif %}
        - {% if filter_form %} -
        - {% include 'inc/search_panel.html' %} - {% block sidebar %}{% endblock %} -
        - {% endif %}
        +{% table_config_form table table_name="ObjectTable" %} +{% endblock %} + +{% block javascript %} + {% endblock %} diff --git a/netbox/templates/home.html b/netbox/templates/home.html index 50a411048..47e0e8a1a 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -6,7 +6,7 @@ {% if new_release %} {# new_release is set only if the current user is a superuser or staff member #} @@ -28,7 +28,7 @@ {{ stats.site_count }}

        Sites

        {% else %} - +

        Sites

        {% endif %}

        Geographic locations

        @@ -38,7 +38,7 @@ {{ stats.tenant_count }}

        Tenants

        {% else %} - +

        Tenants

        {% endif %}

        Customers or departments

        @@ -55,7 +55,7 @@ {{ stats.rack_count }}

        Racks

        {% else %} - +

        Racks

        {% endif %}

        Equipment racks, optionally organized by group

        @@ -65,7 +65,7 @@ {{ stats.devicetype_count }}

        Device Types

        {% else %} - +

        Device Types

        {% endif %}

        Physical hardware models by manufacturer

        @@ -75,7 +75,7 @@ {{ stats.device_count }}

        Devices

        {% else %} - +

        Devices

        {% endif %}

        Rack-mounted network equipment, servers, and other devices

        @@ -86,28 +86,28 @@ {{ stats.cable_count }}

        Cables

        {% else %} - +

        Cables

        {% endif %} {% if perms.dcim.view_interface %} {{ stats.interface_connections_count }}

        Interfaces

        {% else %} - +

        Interfaces

        {% endif %} {% if perms.dcim.view_consoleport and perms.dcim.view_consoleserverport %} {{ stats.console_connections_count }}

        Console

        {% else %} - +

        Console

        {% endif %} {% if perms.dcim.view_powerport and perms.dcim.view_poweroutlet %} {{ stats.power_connections_count }}

        Power

        {% else %} - +

        Power

        {% endif %} @@ -123,7 +123,7 @@ {{ stats.powerfeed_count }}

        Power Feeds

        {% else %} - +

        Power Feeds

        {% endif %}

        Electrical circuits delivering power from panels

        @@ -133,7 +133,7 @@ {{ stats.powerpanel_count }}

        Power Panels

        {% else %} - +

        Power Panels

        {% endif %}

        Electrical panels receiving utility power

        @@ -152,7 +152,7 @@ {{ stats.vrf_count }}

        VRFs

        {% else %} - +

        VRFs

        {% endif %}

        Virtual routing and forwarding tables

        @@ -162,7 +162,7 @@ {{ stats.aggregate_count }}

        Aggregates

        {% else %} - +

        Aggregates

        {% endif %}

        Top-level IP allocations

        @@ -172,7 +172,7 @@ {{ stats.prefix_count }}

        Prefixes

        {% else %} - +

        Prefixes

        {% endif %}

        IPv4 and IPv6 network assignments

        @@ -182,7 +182,7 @@ {{ stats.ipaddress_count }}

        IP Addresses

        {% else %} - +

        IP Addresses

        {% endif %}

        Individual IPv4 and IPv6 addresses

        @@ -192,7 +192,7 @@ {{ stats.vlan_count }}

        VLANs

        {% else %} - +

        VLANs

        {% endif %}

        Layer two domains, identified by VLAN ID

        @@ -209,7 +209,7 @@ {{ stats.provider_count }}

        Providers

        {% else %} - +

        Providers

        {% endif %}

        Organizations which provide circuit connectivity

        @@ -219,7 +219,7 @@ {{ stats.circuit_count }}

        Circuits

        {% else %} - +

        Circuits

        {% endif %}

        Communication links for Internet transit, peering, and other services

        @@ -236,7 +236,7 @@ {{ stats.cluster_count }}

        Clusters

        {% else %} - +

        Clusters

        {% endif %}

        Clusters of physical hosts in which VMs reside

        @@ -246,7 +246,7 @@ {{ stats.virtualmachine_count }}

        Virtual Machines

        {% else %} - +

        Virtual Machines

        {% endif %}

        Virtual compute instances running inside clusters

        @@ -265,7 +265,7 @@ {{ stats.secret_count }}

        Secrets

        {% else %} - +

        Secrets

        {% endif %}

        Cryptographically secured secret data

        @@ -276,22 +276,22 @@
        Reports
        - {% if report_results and perms.extras.view_reportresult %} + {% if report_results and perms.extras.view_report %} {% for result in report_results %} - - + + {% endfor %}
        {{ result.report }}{% include 'extras/inc/report_label.html' %}{{ result.name }}{% include 'extras/inc/job_label.html' %}
        - {% elif perms.extras.view_reportresult %} + {% elif perms.extras.view_report %}
        None found
        {% else %}
        - No permission + No permission
        {% endif %} @@ -305,11 +305,11 @@ {% with action=change.get_action_display|lower %}
        {% if action == 'created' %} - + Created {% elif action == 'updated' %} - + Modified {% elif action == 'deleted' %} - + Deleted {% endif %} {{ change.changed_object_type.name|bettertitle }} {% if change.changed_object.get_absolute_url %} @@ -337,7 +337,7 @@
        {% else %}
        - No permission + No permission
        {% endif %} diff --git a/netbox/templates/import_success.html b/netbox/templates/import_success.html index da9958bdb..b427e450e 100644 --- a/netbox/templates/import_success.html +++ b/netbox/templates/import_success.html @@ -4,7 +4,7 @@

        {% block title %}Import Completed{% endblock %}

        {% include 'responsive_table.html' %} - + Import more {% if return_url %} diff --git a/netbox/templates/inc/created_updated.html b/netbox/templates/inc/created_updated.html index 001bb6b85..58be428c4 100644 --- a/netbox/templates/inc/created_updated.html +++ b/netbox/templates/inc/created_updated.html @@ -1,3 +1,3 @@

        - Created {{ obj.created }} · Updated {{ obj.last_updated|timesince }} ago + Created {{ object.created }} · Updated {{ object.last_updated|timesince }} ago

        diff --git a/netbox/templates/inc/custom_fields_panel.html b/netbox/templates/inc/custom_fields_panel.html index 8c1872273..d5f858f15 100644 --- a/netbox/templates/inc/custom_fields_panel.html +++ b/netbox/templates/inc/custom_fields_panel.html @@ -1,4 +1,4 @@ -{% with custom_fields=obj.get_custom_fields %} +{% with custom_fields=object.get_custom_fields %} {% if custom_fields %}
        @@ -10,9 +10,9 @@ {{ field }} {% if field.type == 'boolean' and value == True %} - + {% elif field.type == 'boolean' and value == False %} - + {% elif field.type == 'url' and value %} {{ value|truncatechars:70 }} {% elif value is not None %} diff --git a/netbox/templates/inc/image_attachments.html b/netbox/templates/inc/image_attachments.html index 2fee4dc78..38be9924d 100644 --- a/netbox/templates/inc/image_attachments.html +++ b/netbox/templates/inc/image_attachments.html @@ -9,7 +9,7 @@ {% for attachment in images %} - + {{ attachment }} {{ attachment.size|filesizeformat }} @@ -17,12 +17,12 @@ {% if perms.extras.change_imageattachment %} - + {% endif %} {% if perms.extras.delete_imageattachment %} - + {% endif %} diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index 765df31cc..74a0aa35d 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -1,7 +1,7 @@ {% load static %} {% load helpers %}
        - {% plugin_full_width_page aggregate %} + {% plugin_full_width_page object %}
        diff --git a/netbox/templates/ipam/aggregate_edit.html b/netbox/templates/ipam/aggregate_edit.html index 3cb83ab54..f27abd663 100644 --- a/netbox/templates/ipam/aggregate_edit.html +++ b/netbox/templates/ipam/aggregate_edit.html @@ -1,4 +1,4 @@ -{% extends 'utilities/obj_edit.html' %} +{% extends 'generic/object_edit.html' %} {% load form_helpers %} {% block form %} @@ -11,6 +11,13 @@ {% render_field form.description %}
        +
        +
        Tenancy
        +
        + {% render_field form.tenant_group %} + {% render_field form.tenant %} +
        +
        {% if form.custom_fields %}
        Custom Fields
        diff --git a/netbox/templates/ipam/aggregate_list.html b/netbox/templates/ipam/aggregate_list.html index 85a2bd36d..c60646a71 100644 --- a/netbox/templates/ipam/aggregate_list.html +++ b/netbox/templates/ipam/aggregate_list.html @@ -1,10 +1,10 @@ -{% extends 'utilities/obj_list.html' %} +{% extends 'generic/object_list.html' %} {% load humanize %} {% block sidebar %}
        - Statistics + Statistics
        • Total IPv4 IPs {{ ipv4_total|intcomma }}
        • diff --git a/netbox/templates/ipam/inc/ipadress_edit_header.html b/netbox/templates/ipam/inc/ipadress_edit_header.html index b8ec3878a..ed9692eea 100644 --- a/netbox/templates/ipam/inc/ipadress_edit_header.html +++ b/netbox/templates/ipam/inc/ipadress_edit_header.html @@ -4,7 +4,7 @@ - {% if 'interface' in request.GET %} + {% if 'interface' in request.GET or 'vminterface' in request.GET %} diff --git a/netbox/templates/ipam/inc/service.html b/netbox/templates/ipam/inc/service.html index 9611be175..826fa99ad 100644 --- a/netbox/templates/ipam/inc/service.html +++ b/netbox/templates/ipam/inc/service.html @@ -1,13 +1,10 @@ - - {{ service.name }} - - - {{ service.get_protocol_display }}/{{ service.port }} - + {{ service.name }} + {{ service.get_protocol_display }} + {{ service.port_list }} {% for ip in service.ipaddresses.all %} - {{ ip.address.ip }}
          + {{ ip.address.ip }}
          {% empty %} All IPs {% endfor %} @@ -15,16 +12,16 @@ {{ service.description }} - + {% if perms.ipam.change_service %} - - + + {% endif %} {% if perms.ipam.delete_service %} - + {% endif %} diff --git a/netbox/templates/ipam/inc/toggle_available.html b/netbox/templates/ipam/inc/toggle_available.html index 21d734d2d..161f6b788 100644 --- a/netbox/templates/ipam/inc/toggle_available.html +++ b/netbox/templates/ipam/inc/toggle_available.html @@ -2,8 +2,12 @@ {% if show_available is not None %} {% endif %} diff --git a/netbox/templates/ipam/inc/vlangroup_header.html b/netbox/templates/ipam/inc/vlangroup_header.html index 221f41994..2507a749f 100644 --- a/netbox/templates/ipam/inc/vlangroup_header.html +++ b/netbox/templates/ipam/inc/vlangroup_header.html @@ -1,14 +1,14 @@
          {% if perms.ipam.add_vlan and first_available_vlan %} - - Add a VLAN + + Add a VLAN {% endif %} {% if perms.ipam.change_vlangroup %} - - + + Edit this VLAN Group {% endif %}
          -

          {{ vlan_group }}

          +

          {{ object }}

          diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 6eba1a5e6..8fe98d4da 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -3,16 +3,17 @@ {% load custom_links %} {% load helpers %} {% load plugins %} +{% load render_table from django_tables2 %} {% block header %}
          @@ -21,7 +22,7 @@
          @@ -29,29 +30,29 @@
        - {% plugin_buttons ipaddress %} + {% plugin_buttons object %} {% if perms.ipam.add_ipaddress %} - {% clone_button ipaddress %} + {% clone_button object %} {% endif %} {% if perms.ipam.change_ipaddress %} - {%edit_button ipaddress %} + {%edit_button object %} {% endif %} {% if perms.ipam.delete_ipaddress %} - {% delete_button ipaddress %} + {% delete_button object %} {% endif %}
        -

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

        - {% include 'inc/created_updated.html' with obj=ipaddress %} +

        {% block title %}{{ object }}{% endblock %}

        + {% include 'inc/created_updated.html' %}
        - {% custom_links ipaddress %} + {% custom_links object %}
        @@ -67,13 +68,13 @@ - + - + - +
        FamilyIPv{{ ipaddress.family }}IPv{{ object.family }}
        VRF - {% if ipaddress.vrf %} - {{ ipaddress.vrf }} + {% if object.vrf %} + {{ object.vrf }} {% else %} Global {% endif %} @@ -82,12 +83,11 @@
        Tenant - {% if ipaddress.tenant %} - {% if ipaddress.tenant.group %} - {{ ipaddress.tenant.group }} - + {% if object.tenant %} + {% if object.tenant.group %} + {{ object.tenant.group }} / {% endif %} - {{ ipaddress.tenant }} + {{ object.tenant }} {% else %} None {% endif %} @@ -96,14 +96,14 @@
        Status - {{ ipaddress.get_status_display }} + {{ object.get_status_display }}
        Role - {% if ipaddress.role %} - {{ ipaddress.get_role_display }} + {% if object.role %} + {{ object.get_role_display }} {% else %} None {% endif %} @@ -111,17 +111,17 @@
        DNS Name{{ ipaddress.dns_name|placeholder }}{{ object.dns_name|placeholder }}
        Description{{ ipaddress.description|placeholder }}{{ object.description|placeholder }}
        Assignment - {% if ipaddress.interface %} - {{ ipaddress.interface.parent }} ({{ ipaddress.interface }}) + {% if object.assigned_object %} + {{ object.assigned_object.parent }} ({{ object.assigned_object }}) {% else %} {% endif %} @@ -130,10 +130,10 @@
        NAT (inside) - {% if ipaddress.nat_inside %} - {{ ipaddress.nat_inside }} - {% if ipaddress.nat_inside.interface %} - ({{ ipaddress.nat_inside.interface.parent }}) + {% if object.nat_inside %} + {{ object.nat_inside }} + {% if object.nat_inside.assigned_object %} + ({{ object.nat_inside.assigned_object.parent }}) {% endif %} {% else %} None @@ -143,8 +143,8 @@
        NAT (outside) - {% if ipaddress.nat_outside %} - {{ ipaddress.nat_outside }} + {% if object.nat_outside %} + {{ object.nat_outside }} {% else %} None {% endif %} @@ -152,22 +152,39 @@
        - {% include 'inc/custom_fields_panel.html' with obj=ipaddress %} - {% include 'extras/inc/tags_panel.html' with tags=ipaddress.tags.all url='ipam:ipaddress_list' %} - {% plugin_left_page ipaddress %} + {% include 'inc/custom_fields_panel.html' %} + {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='ipam:ipaddress_list' %} + {% plugin_left_page object %}
        {% include 'panel_table.html' with table=parent_prefixes_table heading='Parent Prefixes' %} {% if duplicate_ips_table.rows %} - {% include 'panel_table.html' with table=duplicate_ips_table heading='Duplicate IP Addresses' panel_class='danger' %} + {# Custom version of panel_table.html #} +
        +
        + Duplicate IP Addresses + {% if more_duplicate_ips %} +
        + Show all +
        + {% endif %} +
        + {% render_table duplicate_ips_table 'inc/table.html' %} +
        {% endif %} {% include 'utilities/obj_table.html' with table=related_ips_table table_template='panel_table.html' heading='Related IP Addresses' panel_class='default noprint' %} - {% plugin_right_page ipaddress %} + {% plugin_right_page object %}
        - {% plugin_full_width_page ipaddress %} + {% plugin_full_width_page object %}
        {% endblock %} diff --git a/netbox/templates/ipam/ipaddress_assign.html b/netbox/templates/ipam/ipaddress_assign.html index ab163533f..913b6c4b4 100644 --- a/netbox/templates/ipam/ipaddress_assign.html +++ b/netbox/templates/ipam/ipaddress_assign.html @@ -1,4 +1,4 @@ -{% extends 'utilities/obj_edit.html' %} +{% extends 'generic/object_edit.html' %} {% load static %} {% load form_helpers %} {% load helpers %} diff --git a/netbox/templates/ipam/ipaddress_bulk_add.html b/netbox/templates/ipam/ipaddress_bulk_add.html index 5d4f4f7cb..f019d48b8 100644 --- a/netbox/templates/ipam/ipaddress_bulk_add.html +++ b/netbox/templates/ipam/ipaddress_bulk_add.html @@ -1,4 +1,4 @@ -{% extends 'utilities/obj_edit.html' %} +{% extends 'generic/object_edit.html' %} {% load static %} {% load form_helpers %} @@ -26,6 +26,12 @@ {% render_field model_form.tenant %} +
        +
        Tags
        +
        + {% render_field model_form.tags %} +
        +
        {% if model_form.custom_fields %}
        Custom Fields
        diff --git a/netbox/templates/ipam/ipaddress_edit.html b/netbox/templates/ipam/ipaddress_edit.html index d8902595a..eb15a3059 100644 --- a/netbox/templates/ipam/ipaddress_edit.html +++ b/netbox/templates/ipam/ipaddress_edit.html @@ -1,4 +1,4 @@ -{% extends 'utilities/obj_edit.html' %} +{% extends 'generic/object_edit.html' %} {% load static %} {% load form_helpers %} {% load helpers %} @@ -28,39 +28,50 @@ {% render_field form.tenant %}
        - {% if obj.interface %} -
        -
        - Interface Assignment -
        -
        -
        - -
        -

        - {{ obj.interface.parent }} -

        +
        +
        + Interface Assignment +
        +
        + {% with vm_tab_active=form.initial.vminterface %} + +
        +
        + {% render_field form.device %} + {% render_field form.interface %} +
        +
        + {% render_field form.virtual_machine %} + {% render_field form.vminterface %}
        - {% render_field form.interface %} - {% render_field form.primary_for_parent %} -
        + {% endwith %} + {% render_field form.primary_for_parent %}
        - {% endif %} +
        NAT IP (Inside)
        -
        +
        + {% render_field form.nat_region %} {% render_field form.nat_site %} {% render_field form.nat_rack %} {% render_field form.nat_device %}
        - @@ -82,7 +93,3 @@
        {% endif %} {% endblock %} - -{% block javascript %} - -{% endblock %} diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index 4620f6bf4..4b382636b 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -9,10 +9,10 @@
        @@ -21,7 +21,7 @@
        @@ -29,49 +29,49 @@
        - {% plugin_buttons prefix %} + {% plugin_buttons object %} {% if perms.ipam.add_prefix and active_tab == 'prefixes' and first_available_prefix %} - - Add Child Prefix + + Add Child Prefix {% endif %} {% if perms.ipam.add_ipaddress and active_tab == 'ip-addresses' and first_available_ip %} - - + + Add an IP Address {% endif %} {% if perms.ipam.add_prefix %} - {% clone_button prefix %} + {% clone_button object %} {% endif %} {% if perms.ipam.change_prefix %} - {% edit_button prefix %} + {% edit_button object %} {% endif %} {% if perms.ipam.delete_prefix %} - {% delete_button prefix %} + {% delete_button object %} {% endif %}
        -

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

        - {% include 'inc/created_updated.html' with obj=prefix %} +

        {% block title %}{{ object }}{% endblock %}

        + {% include 'inc/created_updated.html' %} {% include 'ipam/inc/toggle_available.html' %}
        - {% custom_links prefix %} + {% custom_links object %}
        @@ -87,13 +87,13 @@ - + - + - +
        FamilyIPv{{ prefix.family }}IPv{{ object.family }}
        VRF - {% if prefix.vrf %} - {{ prefix.vrf }} ({{ prefix.vrf.rd }}) + {% if object.vrf %} + {{ object.vrf }} ({{ object.vrf.rd }}) {% else %} Global {% endif %} @@ -102,12 +102,11 @@
        Tenant - {% if prefix.tenant %} - {% if prefix.tenant.group %} - {{ prefix.tenant.group }} - + {% if object.tenant %} + {% if object.tenant.group %} + {{ object.tenant.group }} / {% endif %} - {{ prefix.tenant }} + {{ object.tenant }} {% else %} None {% endif %} @@ -126,12 +125,11 @@
        Site - {% if prefix.site %} - {% if prefix.site.region %} - {{ prefix.site.region }} - + {% if object.site %} + {% if object.site.region %} + {{ object.site.region }} / {% endif %} - {{ prefix.site }} + {{ object.site }} {% else %} None {% endif %} @@ -140,12 +138,11 @@
        VLAN - {% if prefix.vlan %} - {% if prefix.vlan.group %} - {{ prefix.vlan.group }} - + {% if object.vlan %} + {% if object.vlan.group %} + {{ object.vlan.group }} / {% endif %} - {{ prefix.vlan.display_name }} + {{ object.vlan.display_name }} {% else %} None {% endif %} @@ -154,14 +151,14 @@
        Status - {{ prefix.get_status_display }} + {{ object.get_status_display }}
        Role - {% if prefix.role %} - {{ prefix.role }} + {% if object.role %} + {{ object.role }} {% else %} None {% endif %} @@ -169,39 +166,39 @@
        Description{{ prefix.description|placeholder }}{{ object.description|placeholder }}
        Is a pool - {% if prefix.is_pool %} - + {% if object.is_pool %} + {% else %} - + {% endif %}
        Utilization{% utilization_graph prefix.get_utilization %}{% utilization_graph object.get_utilization %}
        - {% include 'inc/custom_fields_panel.html' with obj=prefix %} - {% include 'extras/inc/tags_panel.html' with tags=prefix.tags.all url='ipam:prefix_list' %} - {% plugin_left_page prefix %} + {% include 'inc/custom_fields_panel.html' %} + {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='ipam:prefix_list' %} + {% plugin_left_page object %}
        {% if duplicate_prefix_table.rows %} {% include 'panel_table.html' with table=duplicate_prefix_table heading='Duplicate Prefixes' panel_class='danger' %} {% endif %} {% include 'panel_table.html' with table=parent_prefix_table heading='Parent Prefixes' panel_class='default' %} - {% plugin_right_page prefix %} + {% plugin_right_page object %}
        - {% plugin_full_width_page prefix %} + {% plugin_full_width_page object %}
        {% endblock %} diff --git a/netbox/templates/ipam/prefix_delete.html b/netbox/templates/ipam/prefix_delete.html index 5ea39dc4c..eb7a22d3c 100644 --- a/netbox/templates/ipam/prefix_delete.html +++ b/netbox/templates/ipam/prefix_delete.html @@ -1,4 +1,4 @@ -{% extends 'utilities/obj_delete.html' %} +{% extends 'generic/object_delete.html' %} {% block message_extra %}

        Note: This will not delete any child prefixes or IP addresses.

        diff --git a/netbox/templates/ipam/prefix_edit.html b/netbox/templates/ipam/prefix_edit.html index 401a53e38..3505c373b 100644 --- a/netbox/templates/ipam/prefix_edit.html +++ b/netbox/templates/ipam/prefix_edit.html @@ -1,4 +1,4 @@ -{% extends 'utilities/obj_edit.html' %} +{% extends 'generic/object_edit.html' %} {% load form_helpers %} {% block form %} @@ -16,6 +16,7 @@
        Site/VLAN Assignment
        + {% render_field form.region %} {% render_field form.site %} {% render_field form.vlan_group %} {% render_field form.vlan %} diff --git a/netbox/templates/ipam/prefix_list.html b/netbox/templates/ipam/prefix_list.html index 00f0b7fe9..fc3fadaee 100644 --- a/netbox/templates/ipam/prefix_list.html +++ b/netbox/templates/ipam/prefix_list.html @@ -1,9 +1,20 @@ -{% extends 'utilities/obj_list.html' %} +{% extends 'generic/object_list.html' %} {% load helpers %} {% block buttons %}
        - Collapse - Expand +
        {% endblock %} diff --git a/netbox/templates/ipam/rir_list.html b/netbox/templates/ipam/rir_list.html index 02f01fc7c..89d31ae7d 100644 --- a/netbox/templates/ipam/rir_list.html +++ b/netbox/templates/ipam/rir_list.html @@ -1,14 +1,14 @@ -{% extends 'utilities/obj_list.html' %} +{% extends 'generic/object_list.html' %} {% block buttons %} {% if request.GET.family == '6' %} - + IPv4 Stats {% else %} - + IPv6 Stats {% endif %} @@ -17,7 +17,7 @@ {% block sidebar %} {% if request.GET.family == '6' %}
        - Numbers shown indicate /64 prefixes. + Numbers shown indicate /64 prefixes.
        {% endif %} {% endblock %} diff --git a/netbox/templates/ipam/routetarget.html b/netbox/templates/ipam/routetarget.html new file mode 100644 index 000000000..3443d0bf4 --- /dev/null +++ b/netbox/templates/ipam/routetarget.html @@ -0,0 +1,100 @@ +{% extends 'base.html' %} +{% load buttons %} +{% load custom_links %} +{% load helpers %} +{% load plugins %} + +{% block header %} +
        +
        + +
        +
        +
        +
        + + + + +
        +
        +
        +
        +
        + {% plugin_buttons object %} + {% if perms.ipam.add_routetarget %} + {% clone_button object %} + {% endif %} + {% if perms.ipam.change_routetarget %} + {% edit_button object %} + {% endif %} + {% if perms.ipam.delete_routetarget %} + {% delete_button object %} + {% endif %} +
        +

        {% block title %}Route target {{ object }}{% endblock %}

        + {% include 'inc/created_updated.html' %} +
        + {% custom_links object %} +
        + +{% endblock %} + +{% block content %} +
        +
        +
        +
        + Route Target +
        + + + + + + + + + + + + + +
        Name{{ object.name }}
        Tenant + {% if object.tenant %} + {{ object.tenant }} + {% else %} + None + {% endif %} +
        Description{{ object.description|placeholder }}
        +
        + {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='ipam:routetarget_list' %} + {% include 'inc/custom_fields_panel.html' %} + {% plugin_left_page object %} +
        +
        + {% include 'panel_table.html' with table=importing_vrfs_table heading="Importing VRFs" %} + {% include 'panel_table.html' with table=exporting_vrfs_table heading="Exporting VRFs" %} + {% plugin_right_page object %} +
        +
        +
        +
        + {% plugin_full_width_page object %} +
        +
        +{% endblock %} diff --git a/netbox/templates/ipam/service.html b/netbox/templates/ipam/service.html index b16e99aa3..d3e94b90d 100644 --- a/netbox/templates/ipam/service.html +++ b/netbox/templates/ipam/service.html @@ -9,8 +9,8 @@
        @@ -19,7 +19,7 @@
        @@ -27,18 +27,18 @@
        - {% plugin_buttons service %} + {% plugin_buttons object %} {% if perms.dcim.change_service %} - {% edit_button service %} + {% edit_button object %} {% endif %} {% if perms.dcim.delete_service %} - {% delete_button service %} + {% delete_button object %} {% endif %}
        -

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

        -{% include 'inc/created_updated.html' with obj=service %} +

        {% block title %}{{ object }}{% endblock %}

        +{% include 'inc/created_updated.html' %}
        - {% custom_links service %} + {% custom_links object %}
        @@ -49,26 +49,26 @@ - + - + - - + + - +
        Name{{ service.name }}{{ object.name }}
        Parent - {{ service.parent }} + {{ object.parent }}
        Protocol{{ service.get_protocol_display }}{{ object.get_protocol_display }}
        Port{{ service.port }}Ports{{ object.port_list }}
        IP Addresses - {% for ipaddress in service.ipaddresses.all %} + {% for ipaddress in object.ipaddresses.all %} {{ ipaddress }}
        {% empty %} None @@ -77,21 +77,21 @@
        Description{{ service.description|placeholder }}{{ object.description|placeholder }}
        - {% include 'inc/custom_fields_panel.html' with obj=service %} - {% include 'extras/inc/tags_panel.html' with tags=service.tags.all url='ipam:service_list' %} - {% plugin_left_page service %} + {% include 'inc/custom_fields_panel.html' %} + {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='ipam:service_list' %} + {% plugin_left_page object %}
        - {% plugin_right_page service %} + {% plugin_right_page object %}
        - {% plugin_full_width_page service %} + {% plugin_full_width_page object %}
        {% endblock %} diff --git a/netbox/templates/ipam/service_edit.html b/netbox/templates/ipam/service_edit.html index 521aec137..8d6fde9e9 100644 --- a/netbox/templates/ipam/service_edit.html +++ b/netbox/templates/ipam/service_edit.html @@ -1,4 +1,4 @@ -{% extends 'utilities/obj_edit.html' %} +{% extends 'generic/object_edit.html' %} {% load form_helpers %} {% block form %} @@ -22,10 +22,11 @@ {% endif %} {% render_field form.name %}
        - +
        {{ form.protocol }} - {{ form.port }} + {{ form.ports }} + {{ form.ports.help_text }}
        {% render_field form.ipaddresses %} diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index ec1f94b51..d2967ca56 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -9,13 +9,13 @@
        @@ -24,7 +24,7 @@
        @@ -32,32 +32,35 @@
        - {% plugin_buttons vlan %} + {% plugin_buttons object %} {% if perms.ipam.add_vlan %} - {% clone_button vlan %} + {% clone_button object %} {% endif %} {% if perms.ipam.change_vlan %} - {% edit_button vlan %} + {% edit_button object %} {% endif %} {% if perms.ipam.delete_vlan %} - {% delete_button vlan %} + {% delete_button object %} {% endif %}
        -

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

        - {% include 'inc/created_updated.html' with obj=vlan %} +

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

        + {% include 'inc/created_updated.html' %}
        - {% custom_links vlan %} + {% custom_links object %}
        @@ -74,12 +77,11 @@ Site - {% if vlan.site %} - {% if vlan.site.region %} - {{ vlan.site.region }} - + {% if object.site %} + {% if object.site.region %} + {{ object.site.region }} / {% endif %} - {{ vlan.site }} + {{ object.site }} {% else %} None {% endif %} @@ -88,8 +90,8 @@ Group - {% if vlan.group %} - {{ vlan.group }} + {% if object.group %} + {{ object.group }} {% else %} None {% endif %} @@ -97,21 +99,20 @@ VLAN ID - {{ vlan.vid }} + {{ object.vid }} Name - {{ vlan.name }} + {{ object.name }} Tenant - {% if vlan.tenant %} - {% if vlan.tenant.group %} - {{ vlan.tenant.group }} - + {% if object.tenant %} + {% if object.tenant.group %} + {{ object.tenant.group }} / {% endif %} - {{ vlan.tenant }} + {{ object.tenant }} {% else %} None {% endif %} @@ -120,14 +121,14 @@ Status - {{ vlan.get_status_display }} + {{ object.get_status_display }} Role - {% if vlan.role %} - {{ vlan.role }} + {% if object.role %} + {{ object.role }} {% else %} None {% endif %} @@ -135,13 +136,13 @@ Description - {{ vlan.description|placeholder }} + {{ object.description|placeholder }} - {% include 'inc/custom_fields_panel.html' with obj=vlan %} - {% include 'extras/inc/tags_panel.html' with tags=vlan.tags.all url='ipam:vlan_list' %} - {% plugin_left_page vlan %} + {% include 'inc/custom_fields_panel.html' %} + {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='ipam:vlan_list' %} + {% plugin_left_page object %}
        @@ -151,19 +152,19 @@ {% include 'responsive_table.html' with table=prefix_table %} {% if perms.ipam.add_prefix %} {% endif %}
        - {% plugin_right_page vlan %} + {% plugin_right_page object %}
        - {% plugin_full_width_page vlan %} + {% plugin_full_width_page object %}
        {% endblock %} diff --git a/netbox/templates/ipam/vlan_edit.html b/netbox/templates/ipam/vlan_edit.html index 1c191343d..54dcf727a 100644 --- a/netbox/templates/ipam/vlan_edit.html +++ b/netbox/templates/ipam/vlan_edit.html @@ -1,4 +1,4 @@ -{% extends 'utilities/obj_edit.html' %} +{% extends 'generic/object_edit.html' %} {% load form_helpers %} {% block form %} @@ -8,12 +8,18 @@ {% render_field form.vid %} {% render_field form.name %} {% render_field form.status %} - {% render_field form.site %} - {% render_field form.group %} {% render_field form.role %} {% render_field form.description %} +
        +
        Assignment
        +
        + {% render_field form.region %} + {% render_field form.site %} + {% render_field form.group %} +
        +
        Tenancy
        diff --git a/netbox/templates/ipam/vlan_members.html b/netbox/templates/ipam/vlan_interfaces.html similarity index 59% rename from netbox/templates/ipam/vlan_members.html rename to netbox/templates/ipam/vlan_interfaces.html index 9fc803e09..d58de30c0 100644 --- a/netbox/templates/ipam/vlan_members.html +++ b/netbox/templates/ipam/vlan_interfaces.html @@ -1,11 +1,9 @@ {% extends 'ipam/vlan.html' %} -{% block title %}{{ block.super }} - Members{% endblock %} - {% block content %}
        - {% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='VLAN Members' parent=vlan %} + {% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='Device Interfaces' parent=vlan %}
        {% endblock %} diff --git a/netbox/templates/ipam/vlan_vminterfaces.html b/netbox/templates/ipam/vlan_vminterfaces.html new file mode 100644 index 000000000..55ddc82bd --- /dev/null +++ b/netbox/templates/ipam/vlan_vminterfaces.html @@ -0,0 +1,9 @@ +{% extends 'ipam/vlan.html' %} + +{% block content %} +
        +
        + {% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='Virtual Machine Interfaces' parent=vlan %} +
        +
        +{% endblock %} diff --git a/netbox/templates/ipam/vlangroup_vlans.html b/netbox/templates/ipam/vlangroup_vlans.html index 7f8ac2044..490b7ab2c 100644 --- a/netbox/templates/ipam/vlangroup_vlans.html +++ b/netbox/templates/ipam/vlangroup_vlans.html @@ -1,16 +1,16 @@ {% extends 'base.html' %} -{% block title %}{{ vlan_group }} - VLANs{% endblock %} +{% block title %}{{ object }} - VLANs{% endblock %} {% block content %}
        diff --git a/netbox/templates/ipam/vrf.html b/netbox/templates/ipam/vrf.html index 6fb6d725f..d17f8e9b9 100644 --- a/netbox/templates/ipam/vrf.html +++ b/netbox/templates/ipam/vrf.html @@ -9,7 +9,7 @@
        @@ -18,7 +18,7 @@
        @@ -26,29 +26,29 @@
        - {% plugin_buttons vrf %} + {% plugin_buttons object %} {% if perms.ipam.add_vrf %} - {% clone_button vrf %} + {% clone_button object %} {% endif %} {% if perms.ipam.change_vrf %} - {% edit_button vrf %} + {% edit_button object %} {% endif %} {% if perms.ipam.delete_vrf %} - {% delete_button vrf %} + {% delete_button object %} {% endif %}
        -

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

        - {% include 'inc/created_updated.html' with obj=vrf %} +

        {% block title %}VRF {{ object }}{% endblock %}

        + {% include 'inc/created_updated.html' %}
        - {% custom_links vrf %} + {% custom_links object %}
        @@ -64,13 +64,13 @@ - + - +
        Route Distinguisher{{ vrf.rd }}{{ object.rd }}
        Tenant - {% if vrf.tenant %} - {{ vrf.tenant }} + {% if object.tenant %} + {{ object.tenant }} {% else %} None {% endif %} @@ -79,36 +79,38 @@
        Unique IP Space - {% if vrf.enforce_unique %} - + {% if object.enforce_unique %} + {% else %} - + {% endif %}
        Description{{ vrf.description|placeholder }}{{ object.description|placeholder }}
        Prefixes - {{ prefix_count }} + {{ prefix_count }}
        - {% include 'extras/inc/tags_panel.html' with tags=vrf.tags.all url='ipam:vrf_list' %} - {% plugin_left_page vrf %} + {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='ipam:vrf_list' %} + {% include 'inc/custom_fields_panel.html' %} + {% plugin_left_page object %}
        - {% include 'inc/custom_fields_panel.html' with obj=vrf %} - {% plugin_right_page vrf %} + {% include 'panel_table.html' with table=import_targets_table heading="Import Route Targets" %} + {% include 'panel_table.html' with table=export_targets_table heading="Export Route Targets" %} + {% plugin_right_page object %}
        - {% plugin_full_width_page vrf %} + {% plugin_full_width_page object %}
        {% endblock %} diff --git a/netbox/templates/ipam/vrf_edit.html b/netbox/templates/ipam/vrf_edit.html index a2ff51d9b..189b9c129 100644 --- a/netbox/templates/ipam/vrf_edit.html +++ b/netbox/templates/ipam/vrf_edit.html @@ -1,4 +1,4 @@ -{% extends 'utilities/obj_edit.html' %} +{% extends 'generic/object_edit.html' %} {% load form_helpers %} {% block form %} @@ -11,6 +11,13 @@ {% render_field form.description %} +
        +
        Route Targets
        +
        + {% render_field form.import_targets %} + {% render_field form.export_targets %} +
        +
        Tenancy
        diff --git a/netbox/templates/search.html b/netbox/templates/search.html index 68b69cf94..a11011ed7 100644 --- a/netbox/templates/search.html +++ b/netbox/templates/search.html @@ -14,7 +14,7 @@

        {{ obj_type.name|bettertitle }}

        {% include 'panel_table.html' with table=obj_type.table %} - + {% if obj_type.table.page.has_next %} See all {{ obj_type.table.page.paginator.count }} results {% else %} @@ -56,7 +56,7 @@
        diff --git a/netbox/templates/secrets/inc/assigned_secrets.html b/netbox/templates/secrets/inc/assigned_secrets.html new file mode 100644 index 000000000..594ab43f3 --- /dev/null +++ b/netbox/templates/secrets/inc/assigned_secrets.html @@ -0,0 +1,29 @@ +{% if secrets %} +
        + {% csrf_token %} +
        + + {% for secret in secrets %} + + + + + + + {% endfor %} +
        {{ secret.role }}{{ secret.name }}******** + + + +
        +{% else %} +
        + None found +
        +{% endif %} diff --git a/netbox/templates/secrets/inc/private_key_modal.html b/netbox/templates/secrets/inc/private_key_modal.html index f165df400..5b1d4550b 100644 --- a/netbox/templates/secrets/inc/private_key_modal.html +++ b/netbox/templates/secrets/inc/private_key_modal.html @@ -4,7 +4,7 @@ diff --git a/netbox/templates/secrets/inc/secret_tr.html b/netbox/templates/secrets/inc/secret_tr.html deleted file mode 100644 index 3c60928ae..000000000 --- a/netbox/templates/secrets/inc/secret_tr.html +++ /dev/null @@ -1,23 +0,0 @@ -{% load secret_helpers %} - -
        {{ secret.role }} - {{ secret.name }} - ******** - - {% if secret|decryptable_by:request.user %} - - - - {% else %} - - {% endif %} - - diff --git a/netbox/templates/secrets/secret.html b/netbox/templates/secrets/secret.html index 3ddb2fe98..8921c5c65 100644 --- a/netbox/templates/secrets/secret.html +++ b/netbox/templates/secrets/secret.html @@ -2,7 +2,6 @@ {% load buttons %} {% load custom_links %} {% load helpers %} -{% load secret_helpers %} {% load static %} {% load plugins %} @@ -11,32 +10,33 @@
        - {% plugin_buttons secret %} + {% plugin_buttons object %} {% if perms.secrets.change_secret %} - {% edit_button secret %} + {% edit_button object %} {% endif %} {% if perms.secrets.delete_secret %} - {% delete_button secret %} + {% delete_button object %} {% endif %}
        -

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

        - {% include 'inc/created_updated.html' with obj=secret %} +

        {% block title %}{{ object }}{% endblock %}

        + {% include 'inc/created_updated.html' %}
        - {% custom_links secret %} + {% custom_links object %}
        @@ -51,64 +51,57 @@ - + - + - +
        DeviceAssigned object - {{ secret.device }} + {{ object.assigned_object }}
        Role{{ secret.role }}{{ object.role }}
        Name{{ secret.name|placeholder }}{{ object.name|placeholder }}
        - {% include 'inc/custom_fields_panel.html' with obj=secret %} - {% plugin_left_page secret %} + {% include 'inc/custom_fields_panel.html' %} + {% plugin_left_page object %}
        - {% if secret|decryptable_by:request.user %} -
        -
        - Secret Data -
        -
        -
        - {% csrf_token %} -
        -
        -
        Secret
        -
        ********
        -
        - - - -
        +
        +
        + Secret Data +
        +
        +
        + {% csrf_token %} +
        +
        +
        Secret
        +
        ********
        +
        + + +
        - {% else %} -
        - - You do not have permission to decrypt this secret. -
        - {% endif %} - {% include 'extras/inc/tags_panel.html' with tags=secret.tags.all url='secrets:secret_list' %} - {% plugin_right_page secret %} +
        + {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='secrets:secret_list' %} + {% plugin_right_page object %}
        - {% plugin_full_width_page secret %} + {% plugin_full_width_page object %}
        diff --git a/netbox/templates/secrets/secret_edit.html b/netbox/templates/secrets/secret_edit.html index cb3935521..4e2d78042 100644 --- a/netbox/templates/secrets/secret_edit.html +++ b/netbox/templates/secrets/secret_edit.html @@ -1,7 +1,6 @@ {% extends 'base.html' %} {% load static %} {% load form_helpers %} -{% load secret_helpers %} {% block content %}
        @@ -9,7 +8,7 @@ {{ form.private_key }}
        -

        {% block title %}{% if secret.pk %}Editing {{ secret }}{% else %}Add a Secret{% endif %}{% endblock %}

        +

        {% block title %}{% if obj.pk %}Editing {{ obj }}{% else %}Add a Secret{% endif %}{% endblock %}

        {% if form.non_field_errors %}
        Errors
        @@ -19,9 +18,24 @@
        {% endif %}
        -
        Secret Attributes
        +
        + Secret Assignment +
        - {% render_field form.device %} + {% with vm_tab_active=form.initial.virtual_machine %} + +
        +
        + {% render_field form.device %} +
        +
        + {% render_field form.virtual_machine %} +
        +
        + {% endwith %} {% render_field form.role %} {% render_field form.name %} {% render_field form.userkeys %} @@ -30,18 +44,18 @@
        Secret Data
        - {% if secret.pk and secret|decryptable_by:request.user %} + {% if obj.pk %}
        -

        ********

        +

        ********

        - -
        @@ -69,9 +83,9 @@
        - {% if secret.pk %} + {% if obj.pk %} - Cancel + Cancel {% else %} diff --git a/netbox/templates/secrets/secret_import.html b/netbox/templates/secrets/secret_import.html index bf2f06ae9..0e540eb94 100644 --- a/netbox/templates/secrets/secret_import.html +++ b/netbox/templates/secrets/secret_import.html @@ -1,4 +1,4 @@ -{% extends 'utilities/obj_bulk_import.html' %} +{% extends 'generic/object_bulk_import.html' %} {% load static %} {% block content %} diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index a9cf67398..e2e66e6b2 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -9,10 +9,10 @@
        @@ -21,7 +21,7 @@
        @@ -29,29 +29,29 @@
        - {% plugin_buttons tenant %} + {% plugin_buttons object %} {% if perms.tenancy.add_tenant %} - {% clone_button tenant %} + {% clone_button object %} {% endif %} {% if perms.tenancy.change_tenant %} - {% edit_button tenant %} + {% edit_button object %} {% endif %} {% if perms.tenancy.delete_tenant %} - {% delete_button tenant %} + {% delete_button object %} {% endif %}
        -

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

        - {% include 'inc/created_updated.html' with obj=tenant %} +

        {% block title %}{{ object }}{% endblock %}

        + {% include 'inc/created_updated.html' %}
        - {% custom_links tenant %} + {% custom_links object %}
        @@ -68,8 +68,8 @@ Group - {% if tenant.group %} - {{ tenant.group }} + {% if object.group %} + {{ object.group }} {% else %} None {% endif %} @@ -77,25 +77,25 @@ Description - {{ tenant.description|placeholder }} + {{ object.description|placeholder }}
        - {% include 'inc/custom_fields_panel.html' with obj=tenant %} - {% include 'extras/inc/tags_panel.html' with tags=tenant.tags.all url='tenancy:tenant_list' %} + {% include 'inc/custom_fields_panel.html' %} + {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='tenancy:tenant_list' %}
        Comments
        - {% if tenant.comments %} - {{ tenant.comments|render_markdown }} + {% if object.comments %} + {{ object.comments|render_markdown }} {% else %} None {% endif %}
        - {% plugin_left_page tenant %} + {% plugin_left_page object %}
        - {% plugin_right_page tenant %} + {% plugin_right_page object %}
        - {% plugin_full_width_page tenant %} + {% plugin_full_width_page object %}
        {% endblock %} diff --git a/netbox/templates/tenancy/tenant_edit.html b/netbox/templates/tenancy/tenant_edit.html index 6f58bb450..8a1d1d1b6 100644 --- a/netbox/templates/tenancy/tenant_edit.html +++ b/netbox/templates/tenancy/tenant_edit.html @@ -1,4 +1,4 @@ -{% extends 'utilities/obj_edit.html' %} +{% extends 'generic/object_edit.html' %} {% load static %} {% load form_helpers %} diff --git a/netbox/templates/users/api_tokens.html b/netbox/templates/users/api_tokens.html index 690a966b0..04e7cb23d 100644 --- a/netbox/templates/users/api_tokens.html +++ b/netbox/templates/users/api_tokens.html @@ -18,7 +18,7 @@ Delete {% endif %}
        - + {{ token.key }} {% if token.is_expired %} Expired @@ -57,7 +57,7 @@ {% endfor %} {% if perms.users.add_token %} - + Add a token {% else %} diff --git a/netbox/templates/users/base.html b/netbox/templates/users/base.html index 972b3d7b5..7d064699b 100644 --- a/netbox/templates/users/base.html +++ b/netbox/templates/users/base.html @@ -9,21 +9,21 @@
        diff --git a/netbox/templates/users/preferences.html b/netbox/templates/users/preferences.html index 8b3c4bcc4..1c20e8aaf 100644 --- a/netbox/templates/users/preferences.html +++ b/netbox/templates/users/preferences.html @@ -26,7 +26,7 @@ {% else %} diff --git a/netbox/templates/users/sessionkey_delete.html b/netbox/templates/users/sessionkey_delete.html index c91956b79..e3e8a7efc 100644 --- a/netbox/templates/users/sessionkey_delete.html +++ b/netbox/templates/users/sessionkey_delete.html @@ -1,4 +1,4 @@ -{% extends 'utilities/obj_delete.html' %} +{% extends 'generic/object_delete.html' %} {% block message %}

        Are you sure you want to delete your session key?

        diff --git a/netbox/templates/users/userkey.html b/netbox/templates/users/userkey.html index 2861d187e..09b5bde5b 100644 --- a/netbox/templates/users/userkey.html +++ b/netbox/templates/users/userkey.html @@ -3,39 +3,39 @@ {% block title %}User Key{% endblock %} {% block usercontent %} - {% if userkey %} + {% if object %}

        Your user key is: - {% if userkey.is_active %} + {% if object.is_active %} Active {% else %} Inactive {% endif %}

        - {% include 'inc/created_updated.html' with obj=userkey %} - {% if not userkey.is_active %} + {% include 'inc/created_updated.html' %} + {% if not object.is_active %} {% endif %} -
        {{ userkey.public_key }}
        +
        {{ object.public_key }}

        - {% if userkey.session_key %} + {% if object.session_key %}

        Session key: Active

        - Created {{ userkey.session_key.created }} + Created {{ object.session_key.created }} {% else %}

        No active session key

        {% endif %} @@ -43,7 +43,7 @@

        You don't have a user key on file.

        - + Create a User Key

        diff --git a/netbox/templates/users/userkey_edit.html b/netbox/templates/users/userkey_edit.html index 0715f9038..a7aaa720c 100644 --- a/netbox/templates/users/userkey_edit.html +++ b/netbox/templates/users/userkey_edit.html @@ -5,7 +5,7 @@ {% block title %}User Key{% endblock %} {% block usercontent %} - {% if userkey.is_active %} + {% if object.is_active %} diff --git a/netbox/templates/utilities/render_field.html b/netbox/templates/utilities/render_field.html index e69296873..b383ae4d3 100644 --- a/netbox/templates/utilities/render_field.html +++ b/netbox/templates/utilities/render_field.html @@ -16,14 +16,6 @@ Set null {% endif %} - {% if field.errors %} -
          - {% for error in field.errors %} -
        • {{ error }}
        • - {% endfor %} -
        - {% endif %} -
        {% elif field|widget_type == 'textarea' and not field.label %}
        {{ field }} @@ -35,14 +27,6 @@ {% if field.help_text %} {{ field.help_text|safe }} {% endif %} - {% if field.errors %} -
          - {% for error in field.errors %} -
        • {{ error }}
        • - {% endfor %} -
        - {% endif %} -
        {% else %}
        @@ -55,13 +39,15 @@ {% if field.help_text %} {{ field.help_text|safe }} {% endif %} - {% if field.errors %} -
          - {% for error in field.errors %} -
        • {{ error }}
        • - {% endfor %} -
        - {% endif %} -
        {% endif %} + {% if field.errors %} +
          + {% for error in field.errors %} + {# Embed an HTML comment indicating the error for extraction by tests #} + +
        • {{ error }}
        • + {% endfor %} +
        + {% endif %} +
        diff --git a/netbox/templates/utilities/templatetags/badge.html b/netbox/templates/utilities/templatetags/badge.html new file mode 100644 index 000000000..3b0ddd7d7 --- /dev/null +++ b/netbox/templates/utilities/templatetags/badge.html @@ -0,0 +1 @@ +{% if value or show_empty %}{{ value }}{% endif %} diff --git a/netbox/templates/inc/table_config_form.html b/netbox/templates/utilities/templatetags/table_config_form.html similarity index 66% rename from netbox/templates/inc/table_config_form.html rename to netbox/templates/utilities/templatetags/table_config_form.html index 66844c7ca..c92adaee1 100644 --- a/netbox/templates/inc/table_config_form.html +++ b/netbox/templates/utilities/templatetags/table_config_form.html @@ -1,5 +1,5 @@ {% load form_helpers %} -
        - {% plugin_full_width_page cluster %} + {% plugin_full_width_page object %}
        {% endblock %} diff --git a/netbox/templates/virtualization/cluster_add_devices.html b/netbox/templates/virtualization/cluster_add_devices.html index 397f53d01..d53fc3a03 100644 --- a/netbox/templates/virtualization/cluster_add_devices.html +++ b/netbox/templates/virtualization/cluster_add_devices.html @@ -35,33 +35,3 @@
        {% endblock %} - -{% block javascript %} - -{% endblock %} diff --git a/netbox/templates/virtualization/cluster_edit.html b/netbox/templates/virtualization/cluster_edit.html index c4d39d12e..f43fc717f 100644 --- a/netbox/templates/virtualization/cluster_edit.html +++ b/netbox/templates/virtualization/cluster_edit.html @@ -1,4 +1,4 @@ -{% extends 'utilities/obj_edit.html' %} +{% extends 'generic/object_edit.html' %} {% load form_helpers %} {% block form %} @@ -8,6 +8,7 @@ {% render_field form.name %} {% render_field form.type %} {% render_field form.group %} + {% render_field form.region %} {% render_field form.site %}
        diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index ea8f4fedb..8baec6956 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -9,10 +9,10 @@
        @@ -21,7 +21,7 @@
        @@ -29,34 +29,39 @@
        - {% plugin_buttons virtualmachine %} + {% if perms.virtualization.add_vminterface %} + + Add Interfaces + + {% endif %} + {% plugin_buttons object %} {% if perms.virtualization.add_virtualmachine %} - {% clone_button virtualmachine %} + {% clone_button object %} {% endif %} {% if perms.virtualization.change_virtualmachine %} - {% edit_button virtualmachine %} + {% edit_button object %} {% endif %} {% if perms.virtualization.delete_virtualmachine %} - {% delete_button virtualmachine %} + {% delete_button object %} {% endif %}
        -

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

        - {% include 'inc/created_updated.html' with obj=virtualmachine %} +

        {% block title %}{{ object }}{% endblock %}

        + {% include 'inc/created_updated.html' %}
        - {% custom_links virtualmachine %} + {% custom_links object %}
        @@ -72,19 +77,19 @@ - +
        Name{{ virtualmachine }}{{ object }}
        Status - {{ virtualmachine.get_status_display }} + {{ object.get_status_display }}
        Role - {% if virtualmachine.role %} - {{ virtualmachine.role }} + {% if object.role %} + {{ object.role }} {% else %} None {% endif %} @@ -93,8 +98,8 @@
        Platform - {% if virtualmachine.platform %} - {{ virtualmachine.platform }} + {% if object.platform %} + {{ object.platform }} {% else %} None {% endif %} @@ -103,12 +108,11 @@
        Tenant - {% if virtualmachine.tenant %} - {% if virtualmachine.tenant.group %} - {{ virtualmachine.tenant.group }} - + {% if object.tenant %} + {% if object.tenant.group %} + {{ object.tenant.group }} / {% endif %} - {{ virtualmachine.tenant }} + {{ object.tenant }} {% else %} None {% endif %} @@ -117,12 +121,12 @@
        Primary IPv4 - {% if virtualmachine.primary_ip4 %} - {{ virtualmachine.primary_ip4.address.ip }} - {% if virtualmachine.primary_ip4.nat_inside %} - (NAT for {{ virtualmachine.primary_ip4.nat_inside.address.ip }}) - {% elif virtualmachine.primary_ip4.nat_outside %} - (NAT: {{ virtualmachine.primary_ip4.nat_outside.address.ip }}) + {% if object.primary_ip4 %} + {{ object.primary_ip4.address.ip }} + {% if object.primary_ip4.nat_inside %} + (NAT for {{ object.primary_ip4.nat_inside.address.ip }}) + {% elif object.primary_ip4.nat_outside %} + (NAT: {{ object.primary_ip4.nat_outside.address.ip }}) {% endif %} {% else %} @@ -132,12 +136,12 @@
        Primary IPv6 - {% if virtualmachine.primary_ip6 %} - {{ virtualmachine.primary_ip6.address.ip }} - {% if virtualmachine.primary_ip6.nat_inside %} - (NAT for {{ virtualmachine.primary_ip6.nat_inside.address.ip }}) - {% elif virtualmachine.primary_ip6.nat_outside %} - (NAT: {{ virtualmachine.primary_ip6.nat_outside.address.ip }}) + {% if object.primary_ip6 %} + {{ object.primary_ip6.address.ip }} + {% if object.primary_ip6.nat_inside %} + (NAT for {{ object.primary_ip6.nat_inside.address.ip }}) + {% elif object.primary_ip6.nat_outside %} + (NAT: {{ object.primary_ip6.nat_outside.address.ip }}) {% endif %} {% else %} @@ -146,21 +150,21 @@
        - {% include 'inc/custom_fields_panel.html' with obj=virtualmachine %} - {% include 'extras/inc/tags_panel.html' with tags=virtualmachine.tags.all url='virtualization:virtualmachine_list' %} + {% include 'inc/custom_fields_panel.html' %} + {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='virtualization:virtualmachine_list' %}
        Comments
        - {% if virtualmachine.comments %} - {{ virtualmachine.comments|render_markdown }} + {% if object.comments %} + {{ object.comments|render_markdown }} {% else %} None {% endif %}
        - {% plugin_left_page virtualmachine %} + {% plugin_left_page object %}
        @@ -171,16 +175,15 @@ Cluster - {% if virtualmachine.cluster.group %} - {{ virtualmachine.cluster.group }} - + {% if object.cluster.group %} + {{ object.cluster.group }} / {% endif %} - {{ virtualmachine.cluster }} + {{ object.cluster }} Cluster Type - {{ virtualmachine.cluster.type }} + {{ object.cluster.type }}
        @@ -190,24 +193,24 @@
        - - + + - + - +
        Virtual CPUs{{ virtualmachine.vcpus|placeholder }} Virtual CPUs{{ object.vcpus|placeholder }}
        Memory Memory - {% if virtualmachine.memory %} - {{ virtualmachine.memory }} MB + {% if object.memory %} + {{ object.memory }} MB {% else %} {% endif %}
        Disk Space Disk Space - {% if virtualmachine.disk %} - {{ virtualmachine.disk }} GB + {% if object.disk %} + {{ object.disk }} GB {% else %} {% endif %} @@ -215,6 +218,21 @@
        + {% if perms.secrets.view_secret %} +
        +
        + Secrets +
        + {% include 'secrets/inc/assigned_secrets.html' %} + {% if perms.secrets.add_secret %} + + {% endif %} +
        + {% endif %}
        Services @@ -232,98 +250,73 @@ {% endif %} {% if perms.ipam.add_service %} {% endif %}
        - {% plugin_right_page virtualmachine %} + {% plugin_right_page object %}
        - {% plugin_full_width_page virtualmachine %} + {% plugin_full_width_page object %}
        - {% if perms.dcim.change_interface or perms.dcim.delete_interface %} -
        + {% csrf_token %} - - {% endif %} -
        -
        - Interfaces -
        - -
        -
        - -
        -
        - - - - {% if perms.dcim.change_interface or perms.dcim.delete_interface %} - + +
        +
        + Interfaces +
        + {% if request.user.is_authenticated %} + {% endif %} -
        - - - - - - - - - - - {% for iface in interfaces %} - {% include 'dcim/inc/interface.html' with device=virtualmachine %} - {% empty %} - - - - {% endfor %} - -
        NameLAGDescriptionMTUModeCableConnection
        — No interfaces defined —
        - {% if perms.dcim.add_interface or perms.dcim.delete_interface %} - - {% endif %} -
        - {% if perms.dcim.delete_interface %} -
        - {% endif %} +
        +
        + +
        +
        + {% include 'responsive_table.html' with table=vminterface_table %} + {% if perms.virtualization.add_vminterface or perms.virtualization.delete_vminterface %} + + {% endif %} + + + {% table_config_form vminterface_table %} +{% include 'secrets/inc/private_key_modal.html' %} {% endblock %} {% block javascript %} - + + + {% endblock %} diff --git a/netbox/templates/virtualization/virtualmachine_component_add.html b/netbox/templates/virtualization/virtualmachine_component_add.html index 34a8f3c3d..11b120ee0 100644 --- a/netbox/templates/virtualization/virtualmachine_component_add.html +++ b/netbox/templates/virtualization/virtualmachine_component_add.html @@ -2,7 +2,7 @@ {% load helpers %} {% load form_helpers %} -{% block title %}Create {{ component_type }} ({{ parent }}){% endblock %} +{% block title %}Create {{ component_type }}{% endblock %} {% block content %}
        @@ -22,12 +22,6 @@ {{ component_type|bettertitle }}
        -
        - -
        -

        {{ parent }}

        -
        -
        {% render_form form %}
        diff --git a/netbox/templates/virtualization/virtualmachine_edit.html b/netbox/templates/virtualization/virtualmachine_edit.html index 3be462c4d..6bffabadd 100644 --- a/netbox/templates/virtualization/virtualmachine_edit.html +++ b/netbox/templates/virtualization/virtualmachine_edit.html @@ -1,4 +1,4 @@ -{% extends 'utilities/obj_edit.html' %} +{% extends 'generic/object_edit.html' %} {% load form_helpers %} {% block form %} diff --git a/netbox/templates/virtualization/virtualmachine_list.html b/netbox/templates/virtualization/virtualmachine_list.html index 74839b250..9d8c1720f 100644 --- a/netbox/templates/virtualization/virtualmachine_list.html +++ b/netbox/templates/virtualization/virtualmachine_list.html @@ -1,13 +1,13 @@ -{% extends 'utilities/obj_list.html' %} +{% extends 'generic/object_list.html' %} {% block bulk_buttons %} {% if perms.virtualization.change_virtualmachine %}
        {% endif %} diff --git a/netbox/templates/virtualization/vminterface.html b/netbox/templates/virtualization/vminterface.html new file mode 100644 index 000000000..7263fd9ae --- /dev/null +++ b/netbox/templates/virtualization/vminterface.html @@ -0,0 +1,109 @@ +{% extends 'base.html' %} +{% load helpers %} +{% load plugins %} + +{% block header %} +
        + +
        +
        + {% plugin_buttons object %} + {% if perms.virtualization.change_vminterface %} + + Edit + + {% endif %} + {% if perms.virtualization.delete_vminterface %} + + Delete + + {% endif %} +
        +

        {% block title %}{{ object.virtual_machine }} / {{ object.name }}{% endblock %}

        + +{% endblock %} + +{% block content %} +
        +
        +
        +
        + Interface +
        + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
        Virtual Machine + {{ object.virtual_machine }} +
        Name{{ object.name }}
        Enabled + {% if object.enabled %} + + {% else %} + + {% endif %} +
        Description{{ object.description|placeholder }}
        MTU{{ object.mtu|placeholder }}
        MAC Address{{ object.mac_address|placeholder }}
        802.1Q Mode{{ object.get_mode_display }}
        +
        + {% plugin_left_page object %} +
        +
        + {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} + {% plugin_right_page object %} +
        +
        +
        +
        + {% include 'panel_table.html' with table=ipaddress_table heading="IP Addresses" %} +
        +
        +
        +
        + {% include 'panel_table.html' with table=vlan_table heading="VLANs" %} +
        +
        +
        +
        + {% plugin_full_width_page object %} +
        +
        +{% endblock %} diff --git a/netbox/templates/virtualization/interface_edit.html b/netbox/templates/virtualization/vminterface_edit.html similarity index 52% rename from netbox/templates/virtualization/interface_edit.html rename to netbox/templates/virtualization/vminterface_edit.html index 437b960c9..d1fe6d104 100644 --- a/netbox/templates/virtualization/interface_edit.html +++ b/netbox/templates/virtualization/vminterface_edit.html @@ -1,18 +1,38 @@ -{% extends 'utilities/obj_edit.html' %} +{% extends 'generic/object_edit.html' %} {% load form_helpers %} {% block form %}
        Interface
        + {% if form.instance.virtual_machine %} +
        + + +
        + {% endif %} {% render_field form.name %} {% render_field form.enabled %} {% render_field form.mac_address %} {% render_field form.mtu %} {% render_field form.description %} +
        +
        +
        +
        802.1Q Switching
        +
        {% render_field form.mode %} {% render_field form.untagged_vlan %} {% render_field form.tagged_vlans %} +
        +
        +
        +
        Tags
        +
        {% render_field form.tags %}
        @@ -21,7 +41,7 @@ {% block buttons %} {% if obj.pk %} - + {% else %} diff --git a/netbox/tenancy/api/nested_serializers.py b/netbox/tenancy/api/nested_serializers.py index 80780dba3..7b227c123 100644 --- a/netbox/tenancy/api/nested_serializers.py +++ b/netbox/tenancy/api/nested_serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers +from netbox.api import WritableNestedSerializer from tenancy.models import Tenant, TenantGroup -from utilities.api import WritableNestedSerializer __all__ = [ 'NestedTenantGroupSerializer', @@ -16,10 +16,11 @@ __all__ = [ class NestedTenantGroupSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenantgroup-detail') tenant_count = serializers.IntegerField(read_only=True) + _depth = serializers.IntegerField(source='level', read_only=True) class Meta: model = TenantGroup - fields = ['id', 'url', 'name', 'slug', 'tenant_count'] + fields = ['id', 'url', 'name', 'slug', 'tenant_count', '_depth'] class NestedTenantSerializer(WritableNestedSerializer): diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 9c7a099e4..05e83853e 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -1,9 +1,9 @@ from rest_framework import serializers -from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField from extras.api.customfields import CustomFieldModelSerializer +from extras.api.serializers import TaggedObjectSerializer +from netbox.api import ValidatedModelSerializer from tenancy.models import Tenant, TenantGroup -from utilities.api import ValidatedModelSerializer from .nested_serializers import * @@ -12,17 +12,19 @@ from .nested_serializers import * # class TenantGroupSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenantgroup-detail') parent = NestedTenantGroupSerializer(required=False, allow_null=True) tenant_count = serializers.IntegerField(read_only=True) + _depth = serializers.IntegerField(source='level', read_only=True) class Meta: model = TenantGroup - fields = ['id', 'name', 'slug', 'parent', 'description', 'tenant_count'] + fields = ['id', 'url', 'name', 'slug', 'parent', 'description', 'tenant_count', '_depth'] -class TenantSerializer(TaggitSerializer, CustomFieldModelSerializer): +class TenantSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenant-detail') group = NestedTenantGroupSerializer(required=False) - tags = TagListSerializerField(required=False) circuit_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True) ipaddress_count = serializers.IntegerField(read_only=True) @@ -37,7 +39,7 @@ class TenantSerializer(TaggitSerializer, CustomFieldModelSerializer): class Meta: model = Tenant fields = [ - 'id', 'name', 'slug', 'group', 'description', 'comments', 'tags', 'custom_fields', 'created', + 'id', 'url', 'name', 'slug', 'group', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', 'device_count', 'ipaddress_count', 'prefix_count', 'rack_count', 'site_count', 'virtualmachine_count', 'vlan_count', 'vrf_count', 'cluster_count', ] diff --git a/netbox/tenancy/api/urls.py b/netbox/tenancy/api/urls.py index 645cc2edc..32540879d 100644 --- a/netbox/tenancy/api/urls.py +++ b/netbox/tenancy/api/urls.py @@ -1,18 +1,9 @@ -from rest_framework import routers - +from netbox.api import OrderedDefaultRouter from . import views -class TenancyRootView(routers.APIRootView): - """ - Tenancy API root view - """ - def get_view_name(self): - return 'Tenancy' - - -router = routers.DefaultRouter() -router.APIRootView = TenancyRootView +router = OrderedDefaultRouter() +router.APIRootView = views.TenancyRootView # Tenants router.register('tenant-groups', views.TenantGroupViewSet) diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index 148058a33..2b7ae8365 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -1,22 +1,36 @@ +from rest_framework.routers import APIRootView + from circuits.models import Circuit from dcim.models import Device, Rack, Site from extras.api.views import CustomFieldModelViewSet from ipam.models import IPAddress, Prefix, VLAN, VRF +from netbox.api.views import ModelViewSet from tenancy import filters from tenancy.models import Tenant, TenantGroup -from utilities.api import ModelViewSet -from utilities.utils import get_subquery +from utilities.utils import count_related from virtualization.models import VirtualMachine from . import serializers +class TenancyRootView(APIRootView): + """ + Tenancy API root view + """ + def get_view_name(self): + return 'Tenancy' + + # # Tenant Groups # class TenantGroupViewSet(ModelViewSet): - queryset = TenantGroup.objects.annotate( - tenant_count=get_subquery(Tenant, 'group') + queryset = TenantGroup.objects.add_related_count( + TenantGroup.objects.all(), + Tenant, + 'group', + 'tenant_count', + cumulative=True ) serializer_class = serializers.TenantGroupSerializer filterset_class = filters.TenantGroupFilterSet @@ -30,15 +44,15 @@ class TenantViewSet(CustomFieldModelViewSet): queryset = Tenant.objects.prefetch_related( 'group', 'tags' ).annotate( - circuit_count=get_subquery(Circuit, 'tenant'), - device_count=get_subquery(Device, 'tenant'), - ipaddress_count=get_subquery(IPAddress, 'tenant'), - prefix_count=get_subquery(Prefix, 'tenant'), - rack_count=get_subquery(Rack, 'tenant'), - site_count=get_subquery(Site, 'tenant'), - virtualmachine_count=get_subquery(VirtualMachine, 'tenant'), - vlan_count=get_subquery(VLAN, 'tenant'), - vrf_count=get_subquery(VRF, 'tenant') + circuit_count=count_related(Circuit, 'tenant'), + device_count=count_related(Device, 'tenant'), + ipaddress_count=count_related(IPAddress, 'tenant'), + prefix_count=count_related(Prefix, 'tenant'), + rack_count=count_related(Rack, 'tenant'), + site_count=count_related(Site, 'tenant'), + virtualmachine_count=count_related(VirtualMachine, 'tenant'), + vlan_count=count_related(VLAN, 'tenant'), + vrf_count=count_related(VRF, 'tenant') ) serializer_class = serializers.TenantSerializer filterset_class = filters.TenantFilterSet diff --git a/netbox/tenancy/filters.py b/netbox/tenancy/filters.py index af5ee0b2c..d61081de4 100644 --- a/netbox/tenancy/filters.py +++ b/netbox/tenancy/filters.py @@ -1,7 +1,7 @@ import django_filters from django.db.models import Q -from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet +from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet from utilities.filters import BaseFilterSet, NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter from .models import Tenant, TenantGroup @@ -30,7 +30,7 @@ class TenantGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): fields = ['id', 'name', 'slug', 'description'] -class TenantFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): +class TenantFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet): q = django_filters.CharFilter( method='search', label='Search', diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index bf100f43a..bceab7ce7 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -2,11 +2,11 @@ from django import forms from extras.forms import ( AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelCSVForm, - TagField, ) +from extras.models import Tag from utilities.forms import ( - APISelect, APISelectMultiple, BootstrapMixin, CommentField, CSVModelChoiceField, CSVModelForm, - DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, TagFilterField, + BootstrapMixin, CommentField, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, + DynamicModelMultipleChoiceField, SlugField, TagFilterField, ) from .models import Tenant, TenantGroup @@ -18,10 +18,7 @@ from .models import Tenant, TenantGroup class TenantGroupForm(BootstrapMixin, forms.ModelForm): parent = DynamicModelChoiceField( queryset=TenantGroup.objects.all(), - required=False, - widget=APISelect( - api_url="/api/tenancy/tenant-groups/" - ) + required=False ) slug = SlugField() @@ -57,7 +54,8 @@ class TenantForm(BootstrapMixin, CustomFieldModelForm): required=False ) comments = CommentField() - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -108,10 +106,7 @@ class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm): queryset=TenantGroup.objects.all(), to_field_name='slug', required=False, - widget=APISelectMultiple( - value_field="slug", - null_option=True, - ) + null_option='None' ) tag = TagFilterField(model) @@ -124,51 +119,33 @@ class TenancyForm(forms.Form): tenant_group = DynamicModelChoiceField( queryset=TenantGroup.objects.all(), required=False, - widget=APISelect( - filter_for={ - 'tenant': 'group_id', - }, - attrs={ - 'nullable': 'true', - } - ) + null_option='None', + initial_params={ + 'tenants': '$tenant' + } ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), - required=False + required=False, + query_params={ + 'group_id': '$tenant_group' + } ) - def __init__(self, *args, **kwargs): - - # Initialize helper selector - instance = kwargs.get('instance') - if instance and instance.tenant is not None: - initial = kwargs.get('initial', {}).copy() - initial['tenant_group'] = instance.tenant.group - kwargs['initial'] = initial - - super().__init__(*args, **kwargs) - class TenancyFilterForm(forms.Form): tenant_group = DynamicModelMultipleChoiceField( queryset=TenantGroup.objects.all(), to_field_name='slug', required=False, - widget=APISelectMultiple( - value_field="slug", - null_option=True, - filter_for={ - 'tenant': 'group' - } - ) + null_option='None' ) tenant = DynamicModelMultipleChoiceField( queryset=Tenant.objects.all(), to_field_name='slug', required=False, - widget=APISelectMultiple( - value_field="slug", - null_option=True, - ) + null_option='None', + query_params={ + 'group': '$tenant_group' + } ) diff --git a/netbox/tenancy/migrations/0010_custom_field_data.py b/netbox/tenancy/migrations/0010_custom_field_data.py new file mode 100644 index 000000000..ec05be0ff --- /dev/null +++ b/netbox/tenancy/migrations/0010_custom_field_data.py @@ -0,0 +1,17 @@ +import django.core.serializers.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0009_standardize_description'), + ] + + operations = [ + migrations.AddField( + model_name='tenant', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + ] diff --git a/netbox/tenancy/migrations/0011_standardize_name_length.py b/netbox/tenancy/migrations/0011_standardize_name_length.py new file mode 100644 index 000000000..1e29a0f5e --- /dev/null +++ b/netbox/tenancy/migrations/0011_standardize_name_length.py @@ -0,0 +1,33 @@ +# Generated by Django 3.1 on 2020-10-15 19:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0010_custom_field_data'), + ] + + operations = [ + migrations.AlterField( + model_name='tenant', + name='name', + field=models.CharField(max_length=100, unique=True), + ), + migrations.AlterField( + model_name='tenant', + name='slug', + field=models.SlugField(max_length=100, unique=True), + ), + migrations.AlterField( + model_name='tenantgroup', + name='name', + field=models.CharField(max_length=100, unique=True), + ), + migrations.AlterField( + model_name='tenantgroup', + name='slug', + field=models.SlugField(max_length=100, unique=True), + ), + ] diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 077fb6ad1..3ba644c09 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -1,12 +1,12 @@ -from django.contrib.contenttypes.fields import GenericRelation from django.db import models from django.urls import reverse from mptt.models import MPTTModel, TreeForeignKey from taggit.managers import TaggableManager -from extras.models import CustomFieldModel, ObjectChange, TaggedItem +from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem from extras.utils import extras_features -from utilities.models import ChangeLoggedModel +from utilities.mptt import TreeManager +from utilities.querysets import RestrictedQuerySet from utilities.utils import serialize_object @@ -21,10 +21,11 @@ class TenantGroup(MPTTModel, ChangeLoggedModel): An arbitrary collection of Tenants. """ name = models.CharField( - max_length=50, + max_length=100, unique=True ) slug = models.SlugField( + max_length=100, unique=True ) parent = TreeForeignKey( @@ -40,6 +41,8 @@ class TenantGroup(MPTTModel, ChangeLoggedModel): blank=True ) + objects = TreeManager() + csv_headers = ['name', 'slug', 'parent', 'description'] class Meta: @@ -79,10 +82,11 @@ class Tenant(ChangeLoggedModel, CustomFieldModel): department. """ name = models.CharField( - max_length=30, + max_length=100, unique=True ) slug = models.SlugField( + max_length=100, unique=True ) group = models.ForeignKey( @@ -99,14 +103,10 @@ class Tenant(ChangeLoggedModel, CustomFieldModel): comments = models.TextField( blank=True ) - custom_field_values = GenericRelation( - to='extras.CustomFieldValue', - content_type_field='obj_type', - object_id_field='obj_id' - ) - tags = TaggableManager(through=TaggedItem) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['name', 'slug', 'group', 'description', 'comments'] clone_fields = [ 'group', 'description', diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index 147a20707..9e8be6b18 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -1,25 +1,13 @@ import django_tables2 as tables -from utilities.tables import BaseTable, TagColumn, ToggleColumn +from utilities.tables import BaseTable, ButtonsColumn, LinkedCountColumn, TagColumn, ToggleColumn from .models import Tenant, TenantGroup MPTT_LINK = """ -{% if record.get_children %} - -{% else %} - -{% endif %} - {{ record.name }} - -""" - -TENANTGROUP_ACTIONS = """ - - - -{% if perms.tenancy.change_tenantgroup %} - -{% endif %} +{% for i in record.get_ancestors %} + +{% endfor %} +{{ record.name }} """ COL_TENANT = """ @@ -39,16 +27,15 @@ class TenantGroupTable(BaseTable): pk = ToggleColumn() name = tables.TemplateColumn( template_code=MPTT_LINK, - orderable=False + orderable=False, + attrs={'td': {'class': 'text-nowrap'}} ) - tenant_count = tables.Column( + tenant_count = LinkedCountColumn( + viewname='tenancy:tenant_list', + url_params={'group': 'slug'}, verbose_name='Tenants' ) - actions = tables.TemplateColumn( - template_code=TENANTGROUP_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' - ) + actions = ButtonsColumn(TenantGroup, pk_field='slug') class Meta(BaseTable.Meta): model = TenantGroup diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py index 8da3d7594..7af3c8d79 100644 --- a/netbox/tenancy/tests/test_api.py +++ b/netbox/tenancy/tests/test_api.py @@ -1,8 +1,7 @@ from django.urls import reverse -from rest_framework import status from tenancy.models import Tenant, TenantGroup -from utilities.testing import APITestCase +from utilities.testing import APITestCase, APIViewTestCases class AppTest(APITestCase): @@ -15,235 +14,80 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) -class TenantGroupTest(APITestCase): +class TenantGroupTest(APIViewTestCases.APIViewTestCase): + model = TenantGroup + brief_fields = ['_depth', 'id', 'name', 'slug', 'tenant_count', 'url'] + bulk_update_data = { + 'description': 'New description', + } - def setUp(self): + @classmethod + def setUpTestData(cls): - super().setUp() - - self.parent_tenant_groups = ( - TenantGroup(name='Parent Tenant Group 1', slug='parent-tenant-group-1'), - TenantGroup(name='Parent Tenant Group 2', slug='parent-tenant-group-2'), - ) - for tenantgroup in self.parent_tenant_groups: - tenantgroup.save() - - self.tenant_groups = ( - TenantGroup(name='Tenant Group 1', slug='tenant-group-1', parent=self.parent_tenant_groups[0]), - TenantGroup(name='Tenant Group 2', slug='tenant-group-2', parent=self.parent_tenant_groups[0]), - TenantGroup(name='Tenant Group 3', slug='tenant-group-3', parent=self.parent_tenant_groups[0]), - ) - for tenantgroup in self.tenant_groups: - tenantgroup.save() - - def test_get_tenantgroup(self): - - url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenant_groups[0].pk}) - response = self.client.get(url, **self.header) - - self.assertEqual(response.data['name'], self.tenant_groups[0].name) - - def test_list_tenantgroups(self): - - url = reverse('tenancy-api:tenantgroup-list') - response = self.client.get(url, **self.header) - - self.assertEqual(response.data['count'], 5) - - def test_list_tenantgroups_brief(self): - - url = reverse('tenancy-api:tenantgroup-list') - response = self.client.get('{}?brief=1'.format(url), **self.header) - - self.assertEqual( - sorted(response.data['results'][0]), - ['id', 'name', 'slug', 'tenant_count', 'url'] + parent_tenant_groups = ( + TenantGroup.objects.create(name='Parent Tenant Group 1', slug='parent-tenant-group-1'), + TenantGroup.objects.create(name='Parent Tenant Group 2', slug='parent-tenant-group-2'), ) - def test_create_tenantgroup(self): + TenantGroup.objects.create(name='Tenant Group 1', slug='tenant-group-1', parent=parent_tenant_groups[0]) + TenantGroup.objects.create(name='Tenant Group 2', slug='tenant-group-2', parent=parent_tenant_groups[0]) + TenantGroup.objects.create(name='Tenant Group 3', slug='tenant-group-3', parent=parent_tenant_groups[0]) - data = { - 'name': 'Tenant Group 4', - 'slug': 'tenant-group-4', - 'parent': self.parent_tenant_groups[0].pk, - } - - url = reverse('tenancy-api:tenantgroup-list') - response = self.client.post(url, data, format='json', **self.header) - - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(TenantGroup.objects.count(), 6) - tenantgroup4 = TenantGroup.objects.get(pk=response.data['id']) - self.assertEqual(tenantgroup4.name, data['name']) - self.assertEqual(tenantgroup4.slug, data['slug']) - self.assertEqual(tenantgroup4.parent_id, data['parent']) - - def test_create_tenantgroup_bulk(self): - - data = [ + cls.create_data = [ { 'name': 'Tenant Group 4', 'slug': 'tenant-group-4', - 'parent': self.parent_tenant_groups[0].pk, + 'parent': parent_tenant_groups[1].pk, }, { 'name': 'Tenant Group 5', 'slug': 'tenant-group-5', - 'parent': self.parent_tenant_groups[0].pk, + 'parent': parent_tenant_groups[1].pk, }, { 'name': 'Tenant Group 6', 'slug': 'tenant-group-6', - 'parent': self.parent_tenant_groups[0].pk, + 'parent': parent_tenant_groups[1].pk, }, ] - url = reverse('tenancy-api:tenantgroup-list') - response = self.client.post(url, data, format='json', **self.header) - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(TenantGroup.objects.count(), 8) - self.assertEqual(response.data[0]['name'], data[0]['name']) - self.assertEqual(response.data[1]['name'], data[1]['name']) - self.assertEqual(response.data[2]['name'], data[2]['name']) +class TenantTest(APIViewTestCases.APIViewTestCase): + model = Tenant + brief_fields = ['id', 'name', 'slug', 'url'] + bulk_update_data = { + 'description': 'New description', + } - def test_update_tenantgroup(self): + @classmethod + def setUpTestData(cls): - data = { - 'name': 'Tenant Group X', - 'slug': 'tenant-group-x', - 'parent': self.parent_tenant_groups[1].pk, - } - - url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenant_groups[0].pk}) - response = self.client.put(url, data, format='json', **self.header) - - self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(TenantGroup.objects.count(), 5) - tenantgroup1 = TenantGroup.objects.get(pk=response.data['id']) - self.assertEqual(tenantgroup1.name, data['name']) - self.assertEqual(tenantgroup1.slug, data['slug']) - self.assertEqual(tenantgroup1.parent_id, data['parent']) - - def test_delete_tenantgroup(self): - - url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenant_groups[0].pk}) - response = self.client.delete(url, **self.header) - - self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) - self.assertEqual(TenantGroup.objects.count(), 4) - - -class TenantTest(APITestCase): - - def setUp(self): - - super().setUp() - - self.tenant_groups = ( - TenantGroup(name='Tenant Group 1', slug='tenant-group-1'), - TenantGroup(name='Tenant Group 2', slug='tenant-group-2'), - ) - for tenantgroup in self.tenant_groups: - tenantgroup.save() - - self.tenants = ( - Tenant(name='Test Tenant 1', slug='test-tenant-1', group=self.tenant_groups[0]), - Tenant(name='Test Tenant 2', slug='test-tenant-2', group=self.tenant_groups[0]), - Tenant(name='Test Tenant 3', slug='test-tenant-3', group=self.tenant_groups[0]), - ) - Tenant.objects.bulk_create(self.tenants) - - def test_get_tenant(self): - - url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenants[0].pk}) - response = self.client.get(url, **self.header) - - self.assertEqual(response.data['name'], self.tenants[0].name) - - def test_list_tenants(self): - - url = reverse('tenancy-api:tenant-list') - response = self.client.get(url, **self.header) - - self.assertEqual(response.data['count'], 3) - - def test_list_tenants_brief(self): - - url = reverse('tenancy-api:tenant-list') - response = self.client.get('{}?brief=1'.format(url), **self.header) - - self.assertEqual( - sorted(response.data['results'][0]), - ['id', 'name', 'slug', 'url'] + tenant_groups = ( + TenantGroup.objects.create(name='Tenant Group 1', slug='tenant-group-1'), + TenantGroup.objects.create(name='Tenant Group 2', slug='tenant-group-2'), ) - def test_create_tenant(self): + tenants = ( + Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]), + Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[0]), + Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[0]), + ) + Tenant.objects.bulk_create(tenants) - data = { - 'name': 'Test Tenant 4', - 'slug': 'test-tenant-4', - 'group': self.tenant_groups[0].pk, - } - - url = reverse('tenancy-api:tenant-list') - response = self.client.post(url, data, format='json', **self.header) - - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(Tenant.objects.count(), 4) - tenant4 = Tenant.objects.get(pk=response.data['id']) - self.assertEqual(tenant4.name, data['name']) - self.assertEqual(tenant4.slug, data['slug']) - self.assertEqual(tenant4.group_id, data['group']) - - def test_create_tenant_bulk(self): - - data = [ + cls.create_data = [ { - 'name': 'Test Tenant 4', - 'slug': 'test-tenant-4', + 'name': 'Tenant 4', + 'slug': 'tenant-4', + 'group': tenant_groups[1].pk, }, { - 'name': 'Test Tenant 5', - 'slug': 'test-tenant-5', + 'name': 'Tenant 5', + 'slug': 'tenant-5', + 'group': tenant_groups[1].pk, }, { - 'name': 'Test Tenant 6', - 'slug': 'test-tenant-6', + 'name': 'Tenant 6', + 'slug': 'tenant-6', + 'group': tenant_groups[1].pk, }, ] - - url = reverse('tenancy-api:tenant-list') - response = self.client.post(url, data, format='json', **self.header) - - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(Tenant.objects.count(), 6) - self.assertEqual(response.data[0]['name'], data[0]['name']) - self.assertEqual(response.data[1]['name'], data[1]['name']) - self.assertEqual(response.data[2]['name'], data[2]['name']) - - def test_update_tenant(self): - - data = { - 'name': 'Test Tenant X', - 'slug': 'test-tenant-x', - 'group': self.tenant_groups[1].pk, - } - - url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenants[0].pk}) - response = self.client.put(url, data, format='json', **self.header) - - self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(Tenant.objects.count(), 3) - tenant1 = Tenant.objects.get(pk=response.data['id']) - self.assertEqual(tenant1.name, data['name']) - self.assertEqual(tenant1.slug, data['slug']) - self.assertEqual(tenant1.group_id, data['group']) - - def test_delete_tenant(self): - - url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenants[0].pk}) - response = self.client.delete(url, **self.header) - - self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) - self.assertEqual(Tenant.objects.count(), 2) diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py index ca2c2633f..5b88b84cf 100644 --- a/netbox/tenancy/tests/test_views.py +++ b/netbox/tenancy/tests/test_views.py @@ -49,13 +49,15 @@ class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase): Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[0]), ]) + tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Tenant X', 'slug': 'tenant-x', 'group': tenant_groups[1].pk, 'description': 'A new tenant', 'comments': 'Some comments', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': [t.pk for t in tags], } cls.csv_data = ( diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py index 0218a5674..372308bb8 100644 --- a/netbox/tenancy/urls.py +++ b/netbox/tenancy/urls.py @@ -9,15 +9,16 @@ urlpatterns = [ # Tenant groups path('tenant-groups/', views.TenantGroupListView.as_view(), name='tenantgroup_list'), - path('tenant-groups/add/', views.TenantGroupCreateView.as_view(), name='tenantgroup_add'), + path('tenant-groups/add/', views.TenantGroupEditView.as_view(), name='tenantgroup_add'), path('tenant-groups/import/', views.TenantGroupBulkImportView.as_view(), name='tenantgroup_import'), path('tenant-groups/delete/', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'), path('tenant-groups//edit/', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'), + path('tenant-groups//delete/', views.TenantGroupDeleteView.as_view(), name='tenantgroup_delete'), path('tenant-groups//changelog/', ObjectChangeLogView.as_view(), name='tenantgroup_changelog', kwargs={'model': TenantGroup}), # Tenants path('tenants/', views.TenantListView.as_view(), name='tenant_list'), - path('tenants/add/', views.TenantCreateView.as_view(), name='tenant_add'), + path('tenants/add/', views.TenantEditView.as_view(), name='tenant_add'), path('tenants/import/', views.TenantBulkImportView.as_view(), name='tenant_import'), path('tenants/edit/', views.TenantBulkEditView.as_view(), name='tenant_bulk_edit'), path('tenants/delete/', views.TenantBulkDeleteView.as_view(), name='tenant_bulk_delete'), diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index afc363cd6..9fd77d88e 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -1,14 +1,9 @@ -from django.contrib.auth.mixins import PermissionRequiredMixin -from django.db.models import Count from django.shortcuts import get_object_or_404, render -from django.views.generic import View from circuits.models import Circuit from dcim.models import Site, Rack, Device, RackReservation from ipam.models import IPAddress, Prefix, VLAN, VRF -from utilities.views import ( - BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, -) +from netbox.views import generic from virtualization.models import VirtualMachine, Cluster from . import filters, forms, tables from .models import Tenant, TenantGroup @@ -18,8 +13,7 @@ from .models import Tenant, TenantGroup # Tenant groups # -class TenantGroupListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'tenancy.view_tenantgroup' +class TenantGroupListView(generic.ObjectListView): queryset = TenantGroup.objects.add_related_count( TenantGroup.objects.all(), Tenant, @@ -30,106 +24,90 @@ class TenantGroupListView(PermissionRequiredMixin, ObjectListView): table = tables.TenantGroupTable -class TenantGroupCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'tenancy.add_tenantgroup' - model = TenantGroup +class TenantGroupEditView(generic.ObjectEditView): + queryset = TenantGroup.objects.all() model_form = forms.TenantGroupForm - default_return_url = 'tenancy:tenantgroup_list' -class TenantGroupEditView(TenantGroupCreateView): - permission_required = 'tenancy.change_tenantgroup' +class TenantGroupDeleteView(generic.ObjectDeleteView): + queryset = TenantGroup.objects.all() -class TenantGroupBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'tenancy.add_tenantgroup' +class TenantGroupBulkImportView(generic.BulkImportView): + queryset = TenantGroup.objects.all() model_form = forms.TenantGroupCSVForm table = tables.TenantGroupTable - default_return_url = 'tenancy:tenantgroup_list' -class TenantGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'tenancy.delete_tenantgroup' - queryset = TenantGroup.objects.annotate(tenant_count=Count('tenants')) +class TenantGroupBulkDeleteView(generic.BulkDeleteView): + queryset = TenantGroup.objects.add_related_count( + TenantGroup.objects.all(), + Tenant, + 'group', + 'tenant_count', + cumulative=True + ) table = tables.TenantGroupTable - default_return_url = 'tenancy:tenantgroup_list' # # Tenants # -class TenantListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'tenancy.view_tenant' - queryset = Tenant.objects.prefetch_related('group') +class TenantListView(generic.ObjectListView): + queryset = Tenant.objects.all() filterset = filters.TenantFilterSet filterset_form = forms.TenantFilterForm table = tables.TenantTable -class TenantView(PermissionRequiredMixin, View): - permission_required = 'tenancy.view_tenant' +class TenantView(generic.ObjectView): + queryset = Tenant.objects.prefetch_related('group') - def get(self, request, slug): - - tenant = get_object_or_404(Tenant, slug=slug) + def get_extra_context(self, request, instance): stats = { - 'site_count': Site.objects.filter(tenant=tenant).count(), - 'rack_count': Rack.objects.filter(tenant=tenant).count(), - 'rackreservation_count': RackReservation.objects.filter(tenant=tenant).count(), - 'device_count': Device.objects.filter(tenant=tenant).count(), - 'vrf_count': VRF.objects.filter(tenant=tenant).count(), - 'prefix_count': Prefix.objects.filter(tenant=tenant).count(), - 'ipaddress_count': IPAddress.objects.filter(tenant=tenant).count(), - 'vlan_count': VLAN.objects.filter(tenant=tenant).count(), - 'circuit_count': Circuit.objects.filter(tenant=tenant).count(), - 'virtualmachine_count': VirtualMachine.objects.filter(tenant=tenant).count(), - 'cluster_count': Cluster.objects.filter(tenant=tenant).count(), + 'site_count': Site.objects.restrict(request.user, 'view').filter(tenant=instance).count(), + 'rack_count': Rack.objects.restrict(request.user, 'view').filter(tenant=instance).count(), + 'rackreservation_count': RackReservation.objects.restrict(request.user, 'view').filter(tenant=instance).count(), + 'device_count': Device.objects.restrict(request.user, 'view').filter(tenant=instance).count(), + 'vrf_count': VRF.objects.restrict(request.user, 'view').filter(tenant=instance).count(), + 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(tenant=instance).count(), + 'ipaddress_count': IPAddress.objects.restrict(request.user, 'view').filter(tenant=instance).count(), + 'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(tenant=instance).count(), + 'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(tenant=instance).count(), + 'virtualmachine_count': VirtualMachine.objects.restrict(request.user, 'view').filter(tenant=instance).count(), + 'cluster_count': Cluster.objects.restrict(request.user, 'view').filter(tenant=instance).count(), } - return render(request, 'tenancy/tenant.html', { - 'tenant': tenant, + return { 'stats': stats, - }) + } -class TenantCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'tenancy.add_tenant' - model = Tenant +class TenantEditView(generic.ObjectEditView): + queryset = Tenant.objects.all() model_form = forms.TenantForm template_name = 'tenancy/tenant_edit.html' - default_return_url = 'tenancy:tenant_list' -class TenantEditView(TenantCreateView): - permission_required = 'tenancy.change_tenant' +class TenantDeleteView(generic.ObjectDeleteView): + queryset = Tenant.objects.all() -class TenantDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'tenancy.delete_tenant' - model = Tenant - default_return_url = 'tenancy:tenant_list' - - -class TenantBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'tenancy.add_tenant' +class TenantBulkImportView(generic.BulkImportView): + queryset = Tenant.objects.all() model_form = forms.TenantCSVForm table = tables.TenantTable - default_return_url = 'tenancy:tenant_list' -class TenantBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'tenancy.change_tenant' +class TenantBulkEditView(generic.BulkEditView): queryset = Tenant.objects.prefetch_related('group') filterset = filters.TenantFilterSet table = tables.TenantTable form = forms.TenantBulkEditForm - default_return_url = 'tenancy:tenant_list' -class TenantBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'tenancy.delete_tenant' +class TenantBulkDeleteView(generic.BulkDeleteView): queryset = Tenant.objects.prefetch_related('group') filterset = filters.TenantFilterSet table = tables.TenantTable - default_return_url = 'tenancy:tenant_list' diff --git a/netbox/users/admin.py b/netbox/users/admin.py index 42e651712..f2fe4a0b4 100644 --- a/netbox/users/admin.py +++ b/netbox/users/admin.py @@ -1,12 +1,49 @@ from django import forms from django.contrib import admin from django.contrib.auth.admin import UserAdmin as UserAdmin_ -from django.contrib.auth.models import User +from django.contrib.auth.models import Group, User +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import FieldError, ValidationError +from django.db.models import Q -from .models import Token, UserConfig +from extras.admin import order_content_types +from .models import AdminGroup, AdminUser, ObjectPermission, Token, UserConfig -# Unregister the built-in UserAdmin so that we can use our custom admin view below -admin.site.unregister(User) + +# +# Inline models +# + +class ObjectPermissionInline(admin.TabularInline): + exclude = None + extra = 3 + readonly_fields = ['object_types', 'actions', 'constraints'] + verbose_name = 'Permission' + verbose_name_plural = 'Permissions' + + def get_queryset(self, request): + return super().get_queryset(request).prefetch_related('objectpermission__object_types').nocache() + + @staticmethod + def object_types(instance): + # Don't call .values_list() here because we want to reference the pre-fetched object_types + return ', '.join([ot.name for ot in instance.objectpermission.object_types.all()]) + + @staticmethod + def actions(instance): + return ', '.join(instance.objectpermission.actions) + + @staticmethod + def constraints(instance): + return instance.objectpermission.constraints + + +class GroupObjectPermissionInline(ObjectPermissionInline): + model = AdminGroup.object_permissions.through + + +class UserObjectPermissionInline(ObjectPermissionInline): + model = AdminUser.object_permissions.through class UserConfigInline(admin.TabularInline): @@ -16,13 +53,52 @@ class UserConfigInline(admin.TabularInline): verbose_name = 'Preferences' -@admin.register(User) +# +# Users & groups +# + +# Unregister the built-in GroupAdmin and UserAdmin classes so that we can use our custom admin classes below +admin.site.unregister(Group) +admin.site.unregister(User) + + +@admin.register(AdminGroup) +class GroupAdmin(admin.ModelAdmin): + fields = ('name',) + list_display = ('name', 'user_count') + ordering = ('name',) + search_fields = ('name',) + inlines = [GroupObjectPermissionInline] + + @staticmethod + def user_count(obj): + return obj.user_set.count() + + +@admin.register(AdminUser) class UserAdmin(UserAdmin_): list_display = [ 'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active' ] - inlines = (UserConfigInline,) + fieldsets = ( + (None, {'fields': ('username', 'password', 'first_name', 'last_name', 'email')}), + ('Groups', {'fields': ('groups',)}), + ('Status', { + 'fields': ('is_active', 'is_staff', 'is_superuser'), + }), + ('Important dates', {'fields': ('last_login', 'date_joined')}), + ) + filter_horizontal = ('groups',) + def get_inlines(self, request, obj): + if obj is not None: + return (UserObjectPermissionInline, UserConfigInline) + return () + + +# +# REST API tokens +# class TokenAdminForm(forms.ModelForm): key = forms.CharField( @@ -43,3 +119,175 @@ class TokenAdmin(admin.ModelAdmin): list_display = [ 'key', 'user', 'created', 'expires', 'write_enabled', 'description' ] + + +# +# Permissions +# + +class ObjectPermissionForm(forms.ModelForm): + can_view = forms.BooleanField(required=False) + can_add = forms.BooleanField(required=False) + can_change = forms.BooleanField(required=False) + can_delete = forms.BooleanField(required=False) + + class Meta: + model = ObjectPermission + exclude = [] + help_texts = { + 'actions': 'Actions granted in addition to those listed above', + 'constraints': 'JSON expression of a queryset filter that will return only permitted objects. Leave null ' + 'to match all objects of this type. A list of multiple objects will result in a logical OR ' + 'operation.' + } + labels = { + 'actions': 'Additional actions' + } + widgets = { + 'constraints': forms.Textarea(attrs={'class': 'vLargeTextField'}) + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Make the actions field optional since the admin form uses it only for non-CRUD actions + self.fields['actions'].required = False + + # Format ContentType choices + order_content_types(self.fields['object_types']) + self.fields['object_types'].choices.insert(0, ('', '---------')) + + # Order group and user fields + self.fields['groups'].queryset = self.fields['groups'].queryset.order_by('name') + self.fields['users'].queryset = self.fields['users'].queryset.order_by('username') + + # Check the appropriate checkboxes when editing an existing ObjectPermission + if self.instance.pk: + for action in ['view', 'add', 'change', 'delete']: + if action in self.instance.actions: + self.fields[f'can_{action}'].initial = True + self.instance.actions.remove(action) + + def clean(self): + super().clean() + + object_types = self.cleaned_data.get('object_types') + constraints = self.cleaned_data.get('constraints') + + # Append any of the selected CRUD checkboxes to the actions list + if not self.cleaned_data.get('actions'): + self.cleaned_data['actions'] = list() + for action in ['view', 'add', 'change', 'delete']: + if self.cleaned_data[f'can_{action}'] and action not in self.cleaned_data['actions']: + self.cleaned_data['actions'].append(action) + + # At least one action must be specified + if not self.cleaned_data['actions']: + raise ValidationError("At least one action must be selected.") + + # Validate the specified model constraints by attempting to execute a query. We don't care whether the query + # returns anything; we just want to make sure the specified constraints are valid. + if object_types and constraints: + # Normalize the constraints to a list of dicts + if type(constraints) is not list: + constraints = [constraints] + for ct in object_types: + model = ct.model_class() + try: + model.objects.filter(*[Q(**c) for c in constraints]).exists() + except FieldError as e: + raise ValidationError({ + 'constraints': f'Invalid filter for {model}: {e}' + }) + + +class ActionListFilter(admin.SimpleListFilter): + title = 'action' + parameter_name = 'action' + + def lookups(self, request, model_admin): + options = set() + for action_list in ObjectPermission.objects.values_list('actions', flat=True).distinct(): + options.update(action_list) + return [ + (action, action) for action in sorted(options) + ] + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(actions=[self.value()]) + + +class ObjectTypeListFilter(admin.SimpleListFilter): + title = 'object type' + parameter_name = 'object_type' + + def lookups(self, request, model_admin): + object_types = ObjectPermission.objects.values_list('id', flat=True).distinct() + content_types = ContentType.objects.filter(pk__in=object_types).order_by('app_label', 'model') + return [ + (ct.pk, ct) for ct in content_types + ] + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(object_types=self.value()) + + +@admin.register(ObjectPermission) +class ObjectPermissionAdmin(admin.ModelAdmin): + actions = ('enable', 'disable') + fieldsets = ( + (None, { + 'fields': ('name', 'description', 'enabled') + }), + ('Actions', { + 'fields': (('can_view', 'can_add', 'can_change', 'can_delete'), 'actions') + }), + ('Objects', { + 'fields': ('object_types',) + }), + ('Assignment', { + 'fields': ('groups', 'users') + }), + ('Constraints', { + 'fields': ('constraints',), + 'classes': ('monospace',) + }), + ) + filter_horizontal = ('object_types', 'groups', 'users') + form = ObjectPermissionForm + list_display = [ + 'name', 'enabled', 'list_models', 'list_users', 'list_groups', 'actions', 'constraints', 'description', + ] + list_filter = [ + 'enabled', ActionListFilter, ObjectTypeListFilter, 'groups', 'users' + ] + search_fields = ['actions', 'constraints', 'description', 'name'] + + def get_queryset(self, request): + return super().get_queryset(request).prefetch_related('object_types', 'users', 'groups') + + def list_models(self, obj): + return ', '.join([f"{ct}" for ct in obj.object_types.all()]) + list_models.short_description = 'Models' + + def list_users(self, obj): + return ', '.join([u.username for u in obj.users.all()]) + list_users.short_description = 'Users' + + def list_groups(self, obj): + return ', '.join([g.name for g in obj.groups.all()]) + list_groups.short_description = 'Groups' + + # + # Admin actions + # + + def enable(self, request, queryset): + updated = queryset.update(enabled=True) + self.message_user(request, f"Enabled {updated} permissions") + + def disable(self, request, queryset): + updated = queryset.update(enabled=False) + self.message_user(request, f"Disabled {updated} permissions") diff --git a/netbox/users/api/nested_serializers.py b/netbox/users/api/nested_serializers.py index d1b649713..3b43ca7c9 100644 --- a/netbox/users/api/nested_serializers.py +++ b/netbox/users/api/nested_serializers.py @@ -1,18 +1,48 @@ -from django.contrib.auth.models import User +from django.contrib.auth.models import Group, User +from django.contrib.contenttypes.models import ContentType +from rest_framework import serializers -from utilities.api import WritableNestedSerializer +from netbox.api import ContentTypeField, WritableNestedSerializer +from users.models import ObjectPermission -_all_ = [ +__all__ = [ + 'NestedGroupSerializer', + 'NestedObjectPermissionSerializer', 'NestedUserSerializer', ] -# -# Users -# +class NestedGroupSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='users-api:group-detail') + + class Meta: + model = Group + fields = ['id', 'url', 'name'] + class NestedUserSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='users-api:user-detail') class Meta: model = User - fields = ['id', 'username'] + fields = ['id', 'url', 'username'] + + +class NestedObjectPermissionSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='users-api:objectpermission-detail') + object_types = ContentTypeField( + queryset=ContentType.objects.all(), + many=True + ) + groups = serializers.SerializerMethodField(read_only=True) + users = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = ObjectPermission + fields = ['id', 'url', 'name', 'enabled', 'object_types', 'groups', 'users', 'actions'] + + def get_groups(self, obj): + return [g.name for g in obj.groups.all()] + + def get_users(self, obj): + return [u.username for u in obj.users.all()] diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index 86d350e69..eed0bd80e 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -1,4 +1,73 @@ +from django.contrib.auth.models import Group, User +from django.contrib.contenttypes.models import ContentType +from rest_framework import serializers + +from netbox.api import ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer +from users.models import ObjectPermission from .nested_serializers import * -# Placeholder for future serializers +class UserSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='users-api:user-detail') + groups = SerializedPKRelatedField( + queryset=Group.objects.all(), + serializer=NestedGroupSerializer, + required=False, + many=True + ) + + class Meta: + model = User + fields = ( + 'id', 'url', 'username', 'password', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', + 'date_joined', 'groups', + ) + extra_kwargs = { + 'password': {'write_only': True} + } + + def create(self, validated_data): + """ + Extract the password from validated data and set it separately to ensure proper hash generation. + """ + password = validated_data.pop('password') + user = super().create(validated_data) + user.set_password(password) + user.save() + + return user + + +class GroupSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='users-api:group-detail') + user_count = serializers.IntegerField(read_only=True) + + class Meta: + model = Group + fields = ('id', 'url', 'name', 'user_count') + + +class ObjectPermissionSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='users-api:objectpermission-detail') + object_types = ContentTypeField( + queryset=ContentType.objects.all(), + many=True + ) + groups = SerializedPKRelatedField( + queryset=Group.objects.all(), + serializer=NestedGroupSerializer, + required=False, + many=True + ) + users = SerializedPKRelatedField( + queryset=User.objects.all(), + serializer=NestedUserSerializer, + required=False, + many=True + ) + + class Meta: + model = ObjectPermission + fields = ( + 'id', 'url', 'name', 'description', 'enabled', 'object_types', 'groups', 'users', 'actions', 'constraints', + ) diff --git a/netbox/users/api/urls.py b/netbox/users/api/urls.py new file mode 100644 index 000000000..df2e8c25a --- /dev/null +++ b/netbox/users/api/urls.py @@ -0,0 +1,19 @@ +from netbox.api import OrderedDefaultRouter +from . import views + + +router = OrderedDefaultRouter() +router.APIRootView = views.UsersRootView + +# Users and groups +router.register('users', views.UserViewSet) +router.register('groups', views.GroupViewSet) + +# Permissions +router.register('permissions', views.ObjectPermissionViewSet) + +# User preferences +router.register('config', views.UserConfigViewSet, basename='userconfig') + +app_name = 'users-api' +urlpatterns = router.urls diff --git a/netbox/users/api/views.py b/netbox/users/api/views.py new file mode 100644 index 000000000..7773e54f4 --- /dev/null +++ b/netbox/users/api/views.py @@ -0,0 +1,80 @@ +from django.contrib.auth.models import Group, User +from django.db.models import Count +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.routers import APIRootView +from rest_framework.viewsets import ViewSet + +from netbox.api.views import ModelViewSet +from users import filters +from users.models import ObjectPermission, UserConfig +from utilities.querysets import RestrictedQuerySet +from utilities.utils import deepmerge +from . import serializers + + +class UsersRootView(APIRootView): + """ + Users API root view + """ + def get_view_name(self): + return 'Users' + + +# +# Users and groups +# + +class UserViewSet(ModelViewSet): + queryset = RestrictedQuerySet(model=User).prefetch_related('groups').order_by('username') + serializer_class = serializers.UserSerializer + filterset_class = filters.UserFilterSet + + +class GroupViewSet(ModelViewSet): + queryset = RestrictedQuerySet(model=Group).annotate(user_count=Count('user')).order_by('name') + serializer_class = serializers.GroupSerializer + filterset_class = filters.GroupFilterSet + + +# +# ObjectPermissions +# + +class ObjectPermissionViewSet(ModelViewSet): + queryset = ObjectPermission.objects.prefetch_related('object_types', 'groups', 'users') + serializer_class = serializers.ObjectPermissionSerializer + filterset_class = filters.ObjectPermissionFilterSet + + +# +# User preferences +# + +class UserConfigViewSet(ViewSet): + """ + An API endpoint via which a user can update his or her own UserConfig data (but no one else's). + """ + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return UserConfig.objects.filter(user=self.request.user) + + def list(self, request): + """ + Return the UserConfig for the currently authenticated User. + """ + userconfig = self.get_queryset().first() + + return Response(userconfig.data) + + def patch(self, request): + """ + Update the UserConfig for the currently authenticated User. + """ + # TODO: How can we validate this data? + userconfig = self.get_queryset().first() + userconfig.data = deepmerge(userconfig.data, request.data) + userconfig.save() + + return Response(userconfig.data) diff --git a/netbox/users/filters.py b/netbox/users/filters.py new file mode 100644 index 000000000..359cf9cc7 --- /dev/null +++ b/netbox/users/filters.py @@ -0,0 +1,89 @@ +import django_filters +from django.contrib.auth.models import Group, User +from django.db.models import Q + +from users.models import ObjectPermission +from utilities.filters import BaseFilterSet + +__all__ = ( + 'GroupFilterSet', + 'ObjectPermissionFilterSet', + 'UserFilterSet', +) + + +class GroupFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + + class Meta: + model = Group + fields = ['id', 'name'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter(name__icontains=value) + + +class UserFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + group_id = django_filters.ModelMultipleChoiceFilter( + field_name='groups', + queryset=Group.objects.all(), + label='Group', + ) + group = django_filters.ModelMultipleChoiceFilter( + field_name='groups__name', + queryset=Group.objects.all(), + to_field_name='name', + label='Group (name)', + ) + + class Meta: + model = User + fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_staff', 'is_active'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(username__icontains=value) | + Q(first_name__icontains=value) | + Q(last_name__icontains=value) | + Q(email__icontains=value) + ) + + +class ObjectPermissionFilterSet(BaseFilterSet): + user_id = django_filters.ModelMultipleChoiceFilter( + field_name='users', + queryset=User.objects.all(), + label='User', + ) + user = django_filters.ModelMultipleChoiceFilter( + field_name='users__username', + queryset=User.objects.all(), + to_field_name='username', + label='User (name)', + ) + group_id = django_filters.ModelMultipleChoiceFilter( + field_name='groups', + queryset=Group.objects.all(), + label='Group', + ) + group = django_filters.ModelMultipleChoiceFilter( + field_name='groups__name', + queryset=Group.objects.all(), + to_field_name='name', + label='Group (name)', + ) + + class Meta: + model = ObjectPermission + fields = ['id', 'name', 'enabled', 'object_types'] diff --git a/netbox/users/migrations/0007_proxy_group_user.py b/netbox/users/migrations/0007_proxy_group_user.py new file mode 100644 index 000000000..2aec9e425 --- /dev/null +++ b/netbox/users/migrations/0007_proxy_group_user.py @@ -0,0 +1,46 @@ +# Generated by Django 3.0.6 on 2020-05-29 14:30 + +import django.contrib.auth.models +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0011_update_proxy_permissions'), + ('users', '0006_create_userconfigs'), + ] + + operations = [ + migrations.CreateModel( + name='AdminGroup', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + 'verbose_name': 'Group', + }, + bases=('auth.group',), + managers=[ + ('objects', django.contrib.auth.models.GroupManager()), + ], + ), + migrations.CreateModel( + name='AdminUser', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + 'verbose_name': 'User', + }, + bases=('auth.user',), + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/netbox/users/migrations/0008_objectpermission.py b/netbox/users/migrations/0008_objectpermission.py new file mode 100644 index 000000000..8ed15b6a1 --- /dev/null +++ b/netbox/users/migrations/0008_objectpermission.py @@ -0,0 +1,35 @@ +from django.conf import settings +import django.contrib.postgres.fields +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('auth', '0011_update_proxy_permissions'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('users', '0007_proxy_group_user'), + ] + + operations = [ + migrations.CreateModel( + name='ObjectPermission', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100)), + ('description', models.CharField(blank=True, max_length=200)), + ('enabled', models.BooleanField(default=True)), + ('constraints', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True)), + ('actions', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=30), size=None)), + ('object_types', models.ManyToManyField(limit_choices_to=models.Q(models.Q(models.Q(_negated=True, app_label__in=['admin', 'auth', 'contenttypes', 'sessions', 'taggit', 'users']), models.Q(('app_label', 'auth'), ('model__in', ['group', 'user'])), models.Q(('app_label', 'users'), ('model__in', ['objectpermission', 'token'])), _connector='OR')), related_name='object_permissions', to='contenttypes.ContentType')), + ('groups', models.ManyToManyField(blank=True, related_name='object_permissions', to='auth.Group')), + ('users', models.ManyToManyField(blank=True, related_name='object_permissions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['name'], + 'verbose_name': 'permission', + }, + ), + ] diff --git a/netbox/users/migrations/0009_replicate_permissions.py b/netbox/users/migrations/0009_replicate_permissions.py new file mode 100644 index 000000000..eacb63964 --- /dev/null +++ b/netbox/users/migrations/0009_replicate_permissions.py @@ -0,0 +1,74 @@ +from django.db import migrations +from django.db.models import Q + +ACTIONS = ['view', 'add', 'change', 'delete'] + + +def replicate_permissions(apps, schema_editor): + """ + Replicate all Permission assignments as ObjectPermissions. + """ + Permission = apps.get_model('auth', 'Permission') + ObjectPermission = apps.get_model('users', 'ObjectPermission') + SecretRole = apps.get_model('secrets', 'SecretRole') + + # TODO: Optimize this iteration so that ObjectPermissions with identical sets of users and groups + # are combined into a single ObjectPermission instance. + for perm in Permission.objects.select_related('content_type'): + if perm.codename.split('_')[0] in ACTIONS: + action = perm.codename.split('_')[0] + elif perm.codename == 'activate_userkey': + action = 'change' + elif perm.codename == 'run_script': + action = 'run' + else: + action = perm.codename + + if perm.group_set.exists() or perm.user_set.exists(): + + # Handle replication of SecretRole user/group assignments for Secrets + if perm.codename == 'view_secret': + for secretrole in SecretRole.objects.prefetch_related('users', 'groups'): + obj_perm = ObjectPermission( + name=f'{perm.content_type.app_label}.{perm.codename} ({secretrole.name})'[:100], + actions=[action], + constraints={'role__name': secretrole.name} + ) + obj_perm.save() + obj_perm.object_types.add(perm.content_type) + # Assign only users/groups who both a) are assigned to the SecretRole and b) have the view_secret + # permission + obj_perm.groups.add( + *list(secretrole.groups.filter(permissions=perm)) + ) + obj_perm.users.add(*list(secretrole.users.filter( + Q(user_permissions=perm) | Q(groups__permissions=perm) + ))) + + else: + obj_perm = ObjectPermission( + # Copy name from original Permission object + name=f'{perm.content_type.app_label}.{perm.codename}'[:100], + actions=[action] + ) + obj_perm.save() + obj_perm.object_types.add(perm.content_type) + + if perm.group_set.exists(): + obj_perm.groups.add(*list(perm.group_set.all())) + if perm.user_set.exists(): + obj_perm.users.add(*list(perm.user_set.all())) + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0008_objectpermission'), + ] + + operations = [ + migrations.RunPython( + code=replicate_permissions, + reverse_code=migrations.RunPython.noop + ) + ] diff --git a/netbox/users/migrations/0010_update_jsonfield.py b/netbox/users/migrations/0010_update_jsonfield.py new file mode 100644 index 000000000..1935e58b7 --- /dev/null +++ b/netbox/users/migrations/0010_update_jsonfield.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1b1 on 2020-07-16 16:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0009_replicate_permissions'), + ] + + operations = [ + migrations.AlterField( + model_name='objectpermission', + name='constraints', + field=models.JSONField(blank=True, null=True), + ), + migrations.AlterField( + model_name='userconfig', + name='data', + field=models.JSONField(default=dict), + ), + ] diff --git a/netbox/users/models.py b/netbox/users/models.py index ea5762232..b25a75134 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -1,23 +1,55 @@ import binascii import os -from django.contrib.auth.models import User -from django.contrib.postgres.fields import JSONField +from django.contrib.auth.models import Group, User +from django.contrib.contenttypes.models import ContentType +from django.contrib.postgres.fields import ArrayField from django.core.validators import MinLengthValidator from django.db import models +from django.db.models import Q from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone +from utilities.querysets import RestrictedQuerySet from utilities.utils import flatten_dict __all__ = ( + 'AdminGroup', + 'AdminUser', + 'ObjectPermission', 'Token', 'UserConfig', ) +# +# Proxy models for admin +# + +class AdminGroup(Group): + """ + Proxy contrib.auth.models.Group for the admin UI + """ + class Meta: + verbose_name = 'Group' + proxy = True + + +class AdminUser(User): + """ + Proxy contrib.auth.models.User for the admin UI + """ + class Meta: + verbose_name = 'User' + proxy = True + + +# +# User preferences +# + class UserConfig(models.Model): """ This model stores arbitrary user-specific preferences in a JSON data structure. @@ -27,7 +59,7 @@ class UserConfig(models.Model): on_delete=models.CASCADE, related_name='config' ) - data = JSONField( + data = models.JSONField( default=dict ) @@ -130,6 +162,7 @@ class UserConfig(models.Model): @receiver(post_save, sender=User) +@receiver(post_save, sender=AdminUser) def create_userconfig(instance, created, **kwargs): """ Automatically create a new UserConfig when a new User is created. @@ -138,6 +171,10 @@ def create_userconfig(instance, created, **kwargs): UserConfig(user=instance).save() +# +# REST API +# + class Token(models.Model): """ An API token used for user authentication. This extends the stock model to allow each user to have multiple tokens. @@ -190,3 +227,69 @@ class Token(models.Model): if self.expires is None or timezone.now() < self.expires: return False return True + + +# +# Permissions +# + +class ObjectPermission(models.Model): + """ + A mapping of view, add, change, and/or delete permission for users and/or groups to an arbitrary set of objects + identified by ORM query parameters. + """ + name = models.CharField( + max_length=100 + ) + description = models.CharField( + max_length=200, + blank=True + ) + enabled = models.BooleanField( + default=True + ) + object_types = models.ManyToManyField( + to=ContentType, + limit_choices_to=Q( + ~Q(app_label__in=['admin', 'auth', 'contenttypes', 'sessions', 'taggit', 'users']) | + Q(app_label='auth', model__in=['group', 'user']) | + Q(app_label='users', model__in=['objectpermission', 'token']) + ), + related_name='object_permissions' + ) + groups = models.ManyToManyField( + to=Group, + blank=True, + related_name='object_permissions' + ) + users = models.ManyToManyField( + to=User, + blank=True, + related_name='object_permissions' + ) + actions = ArrayField( + base_field=models.CharField(max_length=30), + help_text="The list of actions granted by this permission" + ) + constraints = models.JSONField( + blank=True, + null=True, + help_text="Queryset filter matching the applicable objects of the selected type(s)" + ) + + objects = RestrictedQuerySet.as_manager() + + class Meta: + ordering = ['name'] + verbose_name = "permission" + + def __str__(self): + return self.name + + def list_constraints(self): + """ + Return all constraint sets as a list (even if only a single set is defined). + """ + if type(self.constraints) is not list: + return [self.constraints] + return self.constraints diff --git a/netbox/users/tests/test_api.py b/netbox/users/tests/test_api.py new file mode 100644 index 000000000..11d4e58cd --- /dev/null +++ b/netbox/users/tests/test_api.py @@ -0,0 +1,194 @@ +from django.contrib.auth.models import Group, User +from django.contrib.contenttypes.models import ContentType +from django.urls import reverse + +from users.models import ObjectPermission +from utilities.testing import APIViewTestCases, APITestCase +from utilities.utils import deepmerge + + +class AppTest(APITestCase): + + def test_root(self): + + url = reverse('users-api:api-root') + response = self.client.get('{}?format=api'.format(url), **self.header) + + self.assertEqual(response.status_code, 200) + + +class UserTest(APIViewTestCases.APIViewTestCase): + model = User + view_namespace = 'users' + brief_fields = ['id', 'url', 'username'] + validation_excluded_fields = ['password'] + create_data = [ + { + 'username': 'User_4', + 'password': 'password4', + }, + { + 'username': 'User_5', + 'password': 'password5', + }, + { + 'username': 'User_6', + 'password': 'password6', + }, + ] + + @classmethod + def setUpTestData(cls): + + users = ( + User(username='User_1'), + User(username='User_2'), + User(username='User_3'), + ) + User.objects.bulk_create(users) + + +class GroupTest(APIViewTestCases.APIViewTestCase): + model = Group + view_namespace = 'users' + brief_fields = ['id', 'name', 'url'] + create_data = [ + { + 'name': 'Group 4', + }, + { + 'name': 'Group 5', + }, + { + 'name': 'Group 6', + }, + ] + + @classmethod + def setUpTestData(cls): + + users = ( + Group(name='Group 1'), + Group(name='Group 2'), + Group(name='Group 3'), + ) + Group.objects.bulk_create(users) + + +class ObjectPermissionTest(APIViewTestCases.APIViewTestCase): + model = ObjectPermission + brief_fields = ['actions', 'enabled', 'groups', 'id', 'name', 'object_types', 'url', 'users'] + + @classmethod + def setUpTestData(cls): + + groups = ( + Group(name='Group 1'), + Group(name='Group 2'), + Group(name='Group 3'), + ) + Group.objects.bulk_create(groups) + + users = ( + User(username='User 1', is_active=True), + User(username='User 2', is_active=True), + User(username='User 3', is_active=True), + ) + User.objects.bulk_create(users) + + object_type = ContentType.objects.get(app_label='dcim', model='device') + + for i in range(3): + objectpermission = ObjectPermission( + name=f'Permission {i+1}', + actions=['view', 'add', 'change', 'delete'], + constraints={'name': f'TEST{i+1}'} + ) + objectpermission.save() + objectpermission.object_types.add(object_type) + objectpermission.groups.add(groups[i]) + objectpermission.users.add(users[i]) + + cls.create_data = [ + { + 'name': 'Permission 4', + 'object_types': ['dcim.site'], + 'groups': [groups[0].pk], + 'users': [users[0].pk], + 'actions': ['view', 'add', 'change', 'delete'], + 'constraints': {'name': 'TEST4'}, + }, + { + 'name': 'Permission 5', + 'object_types': ['dcim.site'], + 'groups': [groups[1].pk], + 'users': [users[1].pk], + 'actions': ['view', 'add', 'change', 'delete'], + 'constraints': {'name': 'TEST5'}, + }, + { + 'name': 'Permission 6', + 'object_types': ['dcim.site'], + 'groups': [groups[2].pk], + 'users': [users[2].pk], + 'actions': ['view', 'add', 'change', 'delete'], + 'constraints': {'name': 'TEST6'}, + }, + ] + + cls.bulk_update_data = { + 'description': 'New description', + } + + +class UserConfigTest(APITestCase): + + def test_get(self): + """ + Retrieve user configuration via GET request. + """ + userconfig = self.user.config + url = reverse('users-api:userconfig-list') + + response = self.client.get(url, **self.header) + self.assertEqual(response.data, {}) + + data = { + "a": 123, + "b": 456, + "c": 789, + } + userconfig.data = data + userconfig.save() + response = self.client.get(url, **self.header) + self.assertEqual(response.data, data) + + def test_patch(self): + """ + Set user config via PATCH requests. + """ + userconfig = self.user.config + url = reverse('users-api:userconfig-list') + + data = { + "a": { + "a1": "X", + "a2": "Y", + }, + "b": { + "b1": "Z", + } + } + response = self.client.patch(url, data=data, format='json', **self.header) + self.assertDictEqual(response.data, data) + userconfig.refresh_from_db() + self.assertDictEqual(userconfig.data, data) + + update_data = { + "c": 123 + } + response = self.client.patch(url, data=update_data, format='json', **self.header) + new_data = deepmerge(data, update_data) + self.assertDictEqual(response.data, new_data) + userconfig.refresh_from_db() + self.assertDictEqual(userconfig.data, new_data) diff --git a/netbox/users/tests/test_filters.py b/netbox/users/tests/test_filters.py new file mode 100644 index 000000000..c3774927c --- /dev/null +++ b/netbox/users/tests/test_filters.py @@ -0,0 +1,192 @@ +from django.contrib.auth.models import Group, User +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase + +from users.filters import GroupFilterSet, ObjectPermissionFilterSet, UserFilterSet +from users.models import ObjectPermission + + +class UserTestCase(TestCase): + queryset = User.objects.all() + filterset = UserFilterSet + + @classmethod + def setUpTestData(cls): + + groups = ( + Group(name='Group 1'), + Group(name='Group 2'), + Group(name='Group 3'), + ) + Group.objects.bulk_create(groups) + + users = ( + User( + username='User1', + first_name='Hank', + last_name='Hill', + email='hank@stricklandpropane.com', + is_staff=True + ), + User( + username='User2', + first_name='Dale', + last_name='Gribble', + email='dale@dalesdeadbug.com' + ), + User( + username='User3', + first_name='Bill', + last_name='Dauterive', + email='bill.dauterive@army.mil' + ), + User( + username='User4', + first_name='Jeff', + last_name='Boomhauer', + email='boomhauer@dangolemail.com' + ), + User( + username='User5', + first_name='Debbie', + last_name='Grund', + is_active=False + ) + ) + User.objects.bulk_create(users) + + users[0].groups.set([groups[0]]) + users[1].groups.set([groups[1]]) + users[2].groups.set([groups[2]]) + + def test_id(self): + params = {'id': self.queryset.values_list('pk', flat=True)[:2]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_username(self): + params = {'username': ['User1', 'User2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_first_name(self): + params = {'first_name': ['Hank', 'Dale']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_last_name(self): + params = {'last_name': ['Hill', 'Gribble']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_email(self): + params = {'email': ['hank@stricklandpropane.com', 'dale@dalesdeadbug.com']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_is_staff(self): + params = {'is_staff': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_is_active(self): + params = {'is_active': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_group(self): + groups = Group.objects.all()[:2] + params = {'group_id': [groups[0].pk, groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'group': [groups[0].name, groups[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class GroupTestCase(TestCase): + queryset = Group.objects.all() + filterset = GroupFilterSet + + @classmethod + def setUpTestData(cls): + + groups = ( + Group(name='Group 1'), + Group(name='Group 2'), + Group(name='Group 3'), + ) + Group.objects.bulk_create(groups) + + def test_id(self): + params = {'id': self.queryset.values_list('pk', flat=True)[:2]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_name(self): + params = {'name': ['Group 1', 'Group 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class ObjectPermissionTestCase(TestCase): + queryset = ObjectPermission.objects.all() + filterset = ObjectPermissionFilterSet + + @classmethod + def setUpTestData(cls): + + groups = ( + Group(name='Group 1'), + Group(name='Group 2'), + Group(name='Group 3'), + ) + Group.objects.bulk_create(groups) + + users = ( + User(username='User1'), + User(username='User2'), + User(username='User3'), + ) + User.objects.bulk_create(users) + + object_types = ( + ContentType.objects.get(app_label='dcim', model='site'), + ContentType.objects.get(app_label='dcim', model='rack'), + ContentType.objects.get(app_label='dcim', model='device'), + ) + + permissions = ( + ObjectPermission(name='Permission 1', actions=['view', 'add', 'change', 'delete']), + ObjectPermission(name='Permission 2', actions=['view', 'add', 'change', 'delete']), + ObjectPermission(name='Permission 3', actions=['view', 'add', 'change', 'delete']), + ObjectPermission(name='Permission 4', actions=['view'], enabled=False), + ObjectPermission(name='Permission 5', actions=['add'], enabled=False), + ObjectPermission(name='Permission 6', actions=['change'], enabled=False), + ObjectPermission(name='Permission 7', actions=['delete'], enabled=False), + ) + ObjectPermission.objects.bulk_create(permissions) + for i in range(0, 3): + permissions[i].groups.set([groups[i]]) + permissions[i].users.set([users[i]]) + permissions[i].object_types.set([object_types[i]]) + + def test_id(self): + params = {'id': self.queryset.values_list('pk', flat=True)[:2]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_name(self): + params = {'name': ['Permission 1', 'Permission 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_enabled(self): + params = {'enabled': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + def test_group(self): + groups = Group.objects.filter(name__in=['Group 1', 'Group 2']) + params = {'group_id': [groups[0].pk, groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'group': [groups[0].name, groups[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_user(self): + users = User.objects.filter(username__in=['User1', 'User2']) + params = {'user_id': [users[0].pk, users[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'user': [users[0].username, users[1].username]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_object_types(self): + object_types = ContentType.objects.filter(model__in=['site', 'rack']) + params = {'object_types': [object_types[0].pk, object_types[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/users/views.py b/netbox/users/views.py index c3e366542..a6d28ecd2 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -3,7 +3,7 @@ import logging from django.conf import settings from django.contrib import messages from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash -from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin +from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.models import update_last_login from django.contrib.auth.signals import user_logged_in from django.http import HttpResponseForbidden, HttpResponseRedirect @@ -38,6 +38,10 @@ class LoginView(View): def get(self, request): form = LoginForm(request) + if request.user.is_authenticated: + logger = logging.getLogger('netbox.auth.login') + return self.redirect_to_next(request, logger) + return render(request, self.template_name, { 'form': form, }) @@ -49,12 +53,6 @@ class LoginView(View): if form.is_valid(): logger.debug("Login form validation was successful") - # Determine where to direct user after successful login - redirect_to = request.POST.get('next') - if redirect_to and not is_safe_url(url=redirect_to, allowed_hosts=request.get_host()): - logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_to}") - redirect_to = reverse('home') - # If maintenance mode is enabled, assume the database is read-only, and disable updating the user's # last_login time upon authentication. if settings.MAINTENANCE_MODE: @@ -66,8 +64,7 @@ class LoginView(View): logger.info(f"User {request.user} successfully authenticated") messages.info(request, "Logged in as {}.".format(request.user)) - logger.debug(f"Redirecting user to {redirect_to}") - return HttpResponseRedirect(redirect_to) + return self.redirect_to_next(request, logger) else: logger.debug("Login form validation failed") @@ -76,6 +73,19 @@ class LoginView(View): 'form': form, }) + def redirect_to_next(self, request, logger): + if request.method == "POST": + redirect_to = request.POST.get('next', reverse('home')) + else: + redirect_to = request.GET.get('next', reverse('home')) + + if redirect_to and not is_safe_url(url=redirect_to, allowed_hosts=request.get_host()): + logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_to}") + redirect_to = reverse('home') + + logger.debug(f"Redirecting user to {redirect_to}") + return HttpResponseRedirect(redirect_to) + class LogoutView(View): """ @@ -175,7 +185,7 @@ class UserKeyView(LoginRequiredMixin, View): userkey = None return render(request, self.template_name, { - 'userkey': userkey, + 'object': userkey, 'active_tab': 'userkey', }) @@ -195,7 +205,7 @@ class UserKeyEditView(LoginRequiredMixin, View): form = UserKeyForm(instance=self.userkey) return render(request, self.template_name, { - 'userkey': self.userkey, + 'object': self.userkey, 'form': form, 'active_tab': 'userkey', }) @@ -283,7 +293,7 @@ class TokenEditView(LoginRequiredMixin, View): form = TokenForm(instance=token) - return render(request, 'utilities/obj_edit.html', { + return render(request, 'generic/object_edit.html', { 'obj': token, 'obj_type': token._meta.verbose_name, 'form': form, @@ -312,7 +322,7 @@ class TokenEditView(LoginRequiredMixin, View): else: return redirect('user:token_list') - return render(request, 'utilities/obj_edit.html', { + return render(request, 'generic/object_edit.html', { 'obj': token, 'obj_type': token._meta.verbose_name, 'form': form, @@ -320,8 +330,7 @@ class TokenEditView(LoginRequiredMixin, View): }) -class TokenDeleteView(PermissionRequiredMixin, View): - permission_required = 'users.delete_token' +class TokenDeleteView(LoginRequiredMixin, View): def get(self, request, pk): @@ -331,7 +340,7 @@ class TokenDeleteView(PermissionRequiredMixin, View): } form = ConfirmationForm(initial=initial_data) - return render(request, 'utilities/obj_delete.html', { + return render(request, 'generic/object_delete.html', { 'obj': token, 'obj_type': token._meta.verbose_name, 'form': form, @@ -347,7 +356,7 @@ class TokenDeleteView(PermissionRequiredMixin, View): messages.success(request, "Token deleted") return redirect('user:token_list') - return render(request, 'utilities/obj_delete.html', { + return render(request, 'generic/object_delete.html', { 'obj': token, 'obj_type': token._meta.verbose_name, 'form': form, diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 205055669..09cc7004b 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -1,30 +1,14 @@ -import logging -from collections import OrderedDict +import platform +import sys -import pytz from django.conf import settings -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist -from django.db.models import ManyToManyField, ProtectedError -from django.http import Http404 +from django.http import JsonResponse from django.urls import reverse -from rest_framework.exceptions import APIException -from rest_framework.permissions import BasePermission -from rest_framework.relations import PrimaryKeyRelatedField, RelatedField -from rest_framework.response import Response -from rest_framework.serializers import Field, ModelSerializer, ValidationError -from rest_framework.viewsets import ModelViewSet as _ModelViewSet, ViewSet +from rest_framework import status +from rest_framework.utils import formatting -from .utils import dict_to_filter_params, dynamic_import - - -class ServiceUnavailable(APIException): - status_code = 503 - default_detail = "Service temporarily unavailable, please try again later." - - -class SerializerNotFound(Exception): - pass +from netbox.api.exceptions import SerializerNotFound +from .utils import dynamic_import def get_serializer_for_model(model, prefix=''): @@ -32,9 +16,10 @@ def get_serializer_for_model(model, prefix=''): Dynamically resolve and return the appropriate serializer for a model. """ app_name, model_name = model._meta.label.split('.') - serializer_name = '{}.api.serializers.{}{}Serializer'.format( - app_name, prefix, model_name - ) + # Serializers for Django's auth models are in the users app + if app_name == 'auth': + app_name = 'users' + serializer_name = f'{app_name}.api.serializers.{prefix}{model_name}Serializer' try: return dynamic_import(serializer_name) except AttributeError: @@ -51,324 +36,37 @@ def is_api_request(request): return request.path_info.startswith(api_path) -# -# Authentication -# - -class IsAuthenticatedOrLoginNotRequired(BasePermission): +def get_view_name(view, suffix=None): """ - Returns True if the user is authenticated or LOGIN_REQUIRED is False. + Derive the view name from its associated model, if it has one. Fall back to DRF's built-in `get_view_name`. """ - def has_permission(self, request, view): - if not settings.LOGIN_REQUIRED: - return True - return request.user.is_authenticated + if hasattr(view, 'queryset'): + # Determine the model name from the queryset. + 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.__class__.__name__ + name = formatting.remove_trailing_string(name, 'View') + name = formatting.remove_trailing_string(name, 'ViewSet') + name = formatting.camelcase_to_spaces(name) + + if suffix: + name += ' ' + suffix + + return name -# -# Fields -# - -class ChoiceField(Field): +def rest_api_server_error(request, *args, **kwargs): """ - Represent a ChoiceField as {'value': , 'label': }. Accepts a single value on write. - - :param choices: An iterable of choices in the form (value, key). - :param allow_blank: Allow blank values in addition to the listed choices. + Handle exceptions and return a useful error message for REST API requests. """ - def __init__(self, choices, allow_blank=False, **kwargs): - self.choiceset = choices - self.allow_blank = allow_blank - self._choices = dict() - - # Unpack grouped choices - for k, v in choices: - if type(v) in [list, tuple]: - for k2, v2 in v: - self._choices[k2] = v2 - else: - self._choices[k] = v - - super().__init__(**kwargs) - - def validate_empty_values(self, data): - # Convert null to an empty string unless allow_null == True - if data is None: - if self.allow_null: - return True, None - else: - data = '' - return super().validate_empty_values(data) - - def to_representation(self, obj): - if obj is '': - return None - data = OrderedDict([ - ('value', obj), - ('label', self._choices[obj]) - ]) - - # TODO: Remove in v2.8 - # Include legacy numeric ID (where applicable) - if hasattr(self.choiceset, 'LEGACY_MAP') and obj in self.choiceset.LEGACY_MAP: - data['id'] = self.choiceset.LEGACY_MAP.get(obj) - - return data - - def to_internal_value(self, data): - if data is '': - if self.allow_blank: - return data - raise ValidationError("This field may not be blank.") - - # Provide an explicit error message if the request is trying to write a dict or list - if isinstance(data, (dict, list)): - raise ValidationError('Value must be passed directly (e.g. "foo": 123); do not use a dictionary or list.') - - # Check for string representations of boolean/integer values - if hasattr(data, 'lower'): - if data.lower() == 'true': - data = True - elif data.lower() == 'false': - data = False - else: - try: - data = int(data) - except ValueError: - pass - - try: - if data in self._choices: - return data - # Check if data is a legacy numeric ID - slug = self.choiceset.id_to_slug(data) - if slug is not None: - return slug - except TypeError: # Input is an unhashable type - pass - - raise ValidationError("{} is not a valid choice.".format(data)) - - @property - def choices(self): - return self._choices - - -class ContentTypeField(RelatedField): - """ - Represent a ContentType as '.' - """ - default_error_messages = { - "does_not_exist": "Invalid content type: {content_type}", - "invalid": "Invalid value. Specify a content type as '.'.", + type_, error, traceback = sys.exc_info() + data = { + 'error': str(error), + 'exception': type_.__name__, + 'netbox_version': settings.VERSION, + 'python_version': platform.python_version(), } - - def to_internal_value(self, data): - try: - app_label, model = data.split('.') - return ContentType.objects.get_by_natural_key(app_label=app_label, model=model) - except ObjectDoesNotExist: - self.fail('does_not_exist', content_type=data) - except (TypeError, ValueError): - self.fail('invalid') - - def to_representation(self, obj): - return "{}.{}".format(obj.app_label, obj.model) - - -class TimeZoneField(Field): - """ - Represent a pytz time zone. - """ - def to_representation(self, obj): - return obj.zone if obj else None - - def to_internal_value(self, data): - if not data: - return "" - if data not in pytz.common_timezones: - raise ValidationError('Unknown time zone "{}" (see pytz.common_timezones for all options)'.format(data)) - return pytz.timezone(data) - - -class SerializedPKRelatedField(PrimaryKeyRelatedField): - """ - Extends PrimaryKeyRelatedField to return a serialized object on read. This is useful for representing related - objects in a ManyToManyField while still allowing a set of primary keys to be written. - """ - def __init__(self, serializer, **kwargs): - self.serializer = serializer - self.pk_field = kwargs.pop('pk_field', None) - super().__init__(**kwargs) - - def to_representation(self, value): - return self.serializer(value, context={'request': self.context['request']}).data - - -# -# Serializers -# - -# TODO: We should probably take a fresh look at exactly what we're doing with this. There might be a more elegant -# way to enforce model validation on the serializer. -class ValidatedModelSerializer(ModelSerializer): - """ - Extends the built-in ModelSerializer to enforce calling clean() on the associated model during validation. - """ - def validate(self, data): - - # Remove custom fields data and tags (if any) prior to model validation - attrs = data.copy() - attrs.pop('custom_fields', None) - attrs.pop('tags', None) - - # Skip ManyToManyFields - for field in self.Meta.model._meta.get_fields(): - if isinstance(field, ManyToManyField): - attrs.pop(field.name, None) - - # Run clean() on an instance of the model - if self.instance is None: - instance = self.Meta.model(**attrs) - else: - instance = self.instance - for k, v in attrs.items(): - setattr(instance, k, v) - instance.clean() - instance.validate_unique() - - return data - - -class WritableNestedSerializer(ModelSerializer): - """ - Returns a nested representation of an object on read, but accepts only a primary key on write. - """ - - def to_internal_value(self, data): - - if data is None: - return None - - # Dictionary of related object attributes - if isinstance(data, dict): - params = dict_to_filter_params(data) - try: - return self.Meta.model.objects.get(**params) - except ObjectDoesNotExist: - raise ValidationError( - "Related object not found using the provided attributes: {}".format(params) - ) - except MultipleObjectsReturned: - raise ValidationError( - "Multiple objects match the provided attributes: {}".format(params) - ) - except FieldError as e: - raise ValidationError(e) - - # Integer PK of related object - if isinstance(data, int): - pk = data - else: - try: - # PK might have been mistakenly passed as a string - pk = int(data) - except (TypeError, ValueError): - raise ValidationError( - "Related objects must be referenced by numeric ID or by dictionary of attributes. Received an " - "unrecognized value: {}".format(data) - ) - - # Look up object by PK - try: - return self.Meta.model.objects.get(pk=int(data)) - except ObjectDoesNotExist: - raise ValidationError( - "Related object not found using the provided numeric ID: {}".format(pk) - ) - - -# -# Viewsets -# - -class ModelViewSet(_ModelViewSet): - """ - Accept either a single object or a list of objects to create. - """ - def get_serializer(self, *args, **kwargs): - - # If a list of objects has been provided, initialize the serializer with many=True - if isinstance(kwargs.get('data', {}), list): - kwargs['many'] = True - - return super().get_serializer(*args, **kwargs) - - def get_serializer_class(self): - logger = logging.getLogger('netbox.api.views.ModelViewSet') - - # If 'brief' has been passed as a query param, find and return the nested serializer for this model, if one - # exists - request = self.get_serializer_context()['request'] - if request.query_params.get('brief'): - logger.debug("Request is for 'brief' format; initializing nested serializer") - try: - serializer = get_serializer_for_model(self.queryset.model, prefix='Nested') - logger.debug(f"Using serializer {serializer}") - return serializer - except SerializerNotFound: - pass - - # Fall back to the hard-coded serializer class - logger.debug(f"Using serializer {self.serializer_class}") - return self.serializer_class - - def dispatch(self, request, *args, **kwargs): - logger = logging.getLogger('netbox.api.views.ModelViewSet') - - try: - return super().dispatch(request, *args, **kwargs) - except ProtectedError as e: - models = [ - '{} ({})'.format(o, o._meta) for o in e.protected_objects.all() - ] - msg = 'Unable to delete object. The following dependent objects were found: {}'.format(', '.join(models)) - logger.warning(msg) - return self.finalize_response( - request, - Response({'detail': msg}, status=409), - *args, - **kwargs - ) - - def list(self, *args, **kwargs): - """ - Call to super to allow for caching - """ - return super().list(*args, **kwargs) - - def retrieve(self, *args, **kwargs): - """ - Call to super to allow for caching - """ - return super().retrieve(*args, **kwargs) - - # - # Logging - # - - def perform_create(self, serializer): - model = serializer.child.Meta.model if hasattr(serializer, 'many') else serializer.Meta.model - logger = logging.getLogger('netbox.api.views.ModelViewSet') - logger.info(f"Creating new {model._meta.verbose_name}") - return super().perform_create(serializer) - - def perform_update(self, serializer): - logger = logging.getLogger('netbox.api.views.ModelViewSet') - logger.info(f"Updating {serializer.instance} (PK: {serializer.instance.pk})") - return super().perform_update(serializer) - - def perform_destroy(self, instance): - logger = logging.getLogger('netbox.api.views.ModelViewSet') - logger.info(f"Deleting {instance} (PK: {instance.pk})") - return super().perform_destroy(instance) + return JsonResponse(data, status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py deleted file mode 100644 index 6342bad2b..000000000 --- a/netbox/utilities/auth_backends.py +++ /dev/null @@ -1,73 +0,0 @@ -import logging - -from django.conf import settings -from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as RemoteUserBackend_ -from django.contrib.auth.models import Group, Permission - - -class ViewExemptModelBackend(ModelBackend): - """ - Custom implementation of Django's stock ModelBackend which allows for the exemption of arbitrary models from view - permission enforcement. - """ - def has_perm(self, user_obj, perm, obj=None): - - # If this is a view permission, check whether the model has been exempted from enforcement - try: - app, codename = perm.split('.') - action, model = codename.split('_') - if action == 'view': - if ( - # All models are exempt from view permission enforcement - '*' in settings.EXEMPT_VIEW_PERMISSIONS - ) or ( - # This specific model is exempt from view permission enforcement - '{}.{}'.format(app, model) in settings.EXEMPT_VIEW_PERMISSIONS - ): - return True - except ValueError: - pass - - return super().has_perm(user_obj, perm, obj) - - -class RemoteUserBackend(ViewExemptModelBackend, RemoteUserBackend_): - """ - Custom implementation of Django's RemoteUserBackend which provides configuration hooks for basic customization. - """ - @property - def create_unknown_user(self): - return settings.REMOTE_AUTH_AUTO_CREATE_USER - - def configure_user(self, request, user): - logger = logging.getLogger('netbox.authentication.RemoteUserBackend') - - # Assign default groups to the user - group_list = [] - for name in settings.REMOTE_AUTH_DEFAULT_GROUPS: - try: - group_list.append(Group.objects.get(name=name)) - except Group.DoesNotExist: - logging.error(f"Could not assign group {name} to remotely-authenticated user {user}: Group not found") - if group_list: - user.groups.add(*group_list) - logger.debug(f"Assigned groups to remotely-authenticated user {user}: {group_list}") - - # Assign default permissions to the user - permissions_list = [] - for permission_name in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS: - try: - app_label, codename = permission_name.split('.') - permissions_list.append( - Permission.objects.get(content_type__app_label=app_label, codename=codename) - ) - except (ValueError, Permission.DoesNotExist): - logging.error( - "Invalid permission name: '{permission_name}'. Permissions must be in the form " - "._. (Example: dcim.add_site)" - ) - if permissions_list: - user.user_permissions.add(*permissions_list) - logger.debug(f"Assigned permissions to remotely-authenticated user {user}: {permissions_list}") - - return user diff --git a/netbox/utilities/choices.py b/netbox/utilities/choices.py index ce0929a8b..3d1002105 100644 --- a/netbox/utilities/choices.py +++ b/netbox/utilities/choices.py @@ -14,7 +14,6 @@ class ChoiceSetMeta(type): class ChoiceSet(metaclass=ChoiceSetMeta): CHOICES = list() - LEGACY_MAP = dict() @classmethod def values(cls): @@ -25,25 +24,6 @@ class ChoiceSet(metaclass=ChoiceSetMeta): # Unpack grouped choices before casting as a dict return dict(unpack_grouped_choices(cls.CHOICES)) - @classmethod - def slug_to_id(cls, slug): - """ - Return the legacy integer value corresponding to a slug. - """ - return cls.LEGACY_MAP.get(slug) - - @classmethod - def id_to_slug(cls, legacy_id): - """ - Return the slug value corresponding to a legacy integer value. - """ - if legacy_id in cls.LEGACY_MAP.values(): - # Invert the legacy map to allow lookup by integer - legacy_map = dict([ - (id, slug) for slug, id in cls.LEGACY_MAP.items() - ]) - return legacy_map.get(legacy_id) - def unpack_grouped_choices(choices): """ diff --git a/netbox/utilities/constants.py b/netbox/utilities/constants.py index 9a3a7d028..8cf047c42 100644 --- a/netbox/utilities/constants.py +++ b/netbox/utilities/constants.py @@ -42,3 +42,25 @@ ADVISORY_LOCK_KEYS = { 'available-prefixes': 100100, 'available-ips': 100200, } + +# +# HTTP Request META safe copy +# + +HTTP_REQUEST_META_SAFE_COPY = [ + 'CONTENT_LENGTH', + 'CONTENT_TYPE', + 'HTTP_ACCEPT', + 'HTTP_ACCEPT_ENCODING', + 'HTTP_ACCEPT_LANGUAGE', + 'HTTP_HOST', + 'HTTP_REFERER', + 'HTTP_USER_AGENT', + 'QUERY_STRING', + 'REMOTE_ADDR', + 'REMOTE_HOST', + 'REMOTE_USER', + 'REQUEST_METHOD', + 'SERVER_NAME', + 'SERVER_PORT', +] diff --git a/netbox/utilities/custom_inspectors.py b/netbox/utilities/custom_inspectors.py index 2cbe1cfc5..95c647fb8 100644 --- a/netbox/utilities/custom_inspectors.py +++ b/netbox/utilities/custom_inspectors.py @@ -1,24 +1,29 @@ from django.contrib.postgres.fields import JSONField from drf_yasg import openapi -from drf_yasg.inspectors import FieldInspector, NotHandled, PaginatorInspector, FilterInspector, SwaggerAutoSchema +from drf_yasg.inspectors import FieldInspector, NotHandled, PaginatorInspector, SwaggerAutoSchema from drf_yasg.utils import get_serializer_ref_name from rest_framework.fields import ChoiceField from rest_framework.relations import ManyRelatedField -from taggit_serializer.serializers import TagListSerializerField -from dcim.api.serializers import InterfaceSerializer as DeviceInterfaceSerializer -from extras.api.customfields import CustomFieldsSerializer -from utilities.api import ChoiceField, SerializedPKRelatedField, WritableNestedSerializer -from virtualization.api.serializers import InterfaceSerializer as VirtualMachineInterfaceSerializer - -# this might be ugly, but it limits drf_yasg-specific code to this file -DeviceInterfaceSerializer.Meta.ref_name = 'DeviceInterface' -VirtualMachineInterfaceSerializer.Meta.ref_name = 'VirtualMachineInterface' +from extras.api.customfields import CustomFieldsDataField +from netbox.api import ChoiceField, SerializedPKRelatedField, WritableNestedSerializer class NetBoxSwaggerAutoSchema(SwaggerAutoSchema): writable_serializers = {} + def get_operation_id(self, operation_keys=None): + operation_keys = operation_keys or self.operation_keys + operation_id = self.overrides.get('operation_id', '') + if not operation_id: + # Overwrite the action for bulk update/bulk delete views to ensure they get an operation ID that's + # unique from their single-object counterparts (see #3436) + if operation_keys[-1] in ('delete', 'partial_update', 'update') and not self.view.detail: + operation_keys[-1] = f'bulk_{operation_keys[-1]}' + operation_id = '_'.join(operation_keys) + + return operation_id + def get_request_serializer(self): serializer = super().get_request_serializer() @@ -56,20 +61,7 @@ class SerializedPKRelatedFieldInspector(FieldInspector): return NotHandled -class TagListFieldInspector(FieldInspector): - def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs): - SwaggerType, ChildSwaggerType = self._get_partial_types(field, swagger_object_type, use_references, **kwargs) - if isinstance(field, TagListSerializerField): - child_schema = self.probe_field_inspectors(field.child, ChildSwaggerType, use_references) - return SwaggerType( - type=openapi.TYPE_ARRAY, - items=child_schema, - ) - - return NotHandled - - -class CustomChoiceFieldInspector(FieldInspector): +class ChoiceFieldInspector(FieldInspector): def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs): # this returns a callable which extracts title, description and other stuff # https://drf-yasg.readthedocs.io/en/stable/_modules/drf_yasg/inspectors/base.html#FieldInspector._get_partial_types @@ -82,8 +74,8 @@ class CustomChoiceFieldInspector(FieldInspector): value_schema = openapi.Schema(type=openapi.TYPE_STRING, enum=choice_value) if set([None] + choice_value) == {None, True, False}: - # DeviceType.subdevice_role, Device.face and InterfaceConnection.connection_status all need to be - # differentiated since they each have subtly different values in their choice keys. + # DeviceType.subdevice_role and Device.face need to be differentiated since they each have + # subtly different values in their choice keys. # - subdevice_role and connection_status are booleans, although subdevice_role includes None # - face is an integer set {0, 1} which is easily confused with {False, True} schema_type = openapi.TYPE_STRING @@ -103,10 +95,6 @@ class CustomChoiceFieldInspector(FieldInspector): return schema - elif isinstance(field, CustomFieldsSerializer): - schema = SwaggerType(type=openapi.TYPE_OBJECT) - return schema - return NotHandled @@ -122,6 +110,17 @@ class NullableBooleanFieldInspector(FieldInspector): return result +class CustomFieldsDataFieldInspector(FieldInspector): + + def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs): + SwaggerType, ChildSwaggerType = self._get_partial_types(field, swagger_object_type, use_references, **kwargs) + + if isinstance(field, CustomFieldsDataField) and swagger_object_type == openapi.Schema: + return SwaggerType(type=openapi.TYPE_OBJECT) + + return NotHandled + + class JSONFieldInspector(FieldInspector): """Required because by default, Swagger sees a JSONField as a string and not dict """ diff --git a/netbox/utilities/error_handlers.py b/netbox/utilities/error_handlers.py index da8510950..1d3bdbafd 100644 --- a/netbox/utilities/error_handlers.py +++ b/netbox/utilities/error_handlers.py @@ -3,35 +3,22 @@ from django.utils.html import escape from django.utils.safestring import mark_safe -def handle_protectederror(obj, request, e): +def handle_protectederror(obj_list, request, e): """ Generate a user-friendly error message in response to a ProtectedError exception. """ - try: - dep_class = e.protected_objects[0]._meta.verbose_name_plural - except IndexError: - raise e - - # Grammar for single versus multiple triggering objects - if type(obj) in (list, tuple): - err_message = "Unable to delete the requested {}. The following dependent {} were found: ".format( - obj[0]._meta.verbose_name_plural, - dep_class, - ) - else: - err_message = "Unable to delete {} {}. The following dependent {} were found: ".format( - obj._meta.verbose_name, - obj, - dep_class, - ) + protected_objects = list(e.protected_objects) + protected_count = len(protected_objects) if len(protected_objects) <= 50 else 'More than 50' + err_message = f"Unable to delete {', '.join(str(obj) for obj in obj_list)}. " \ + f"{protected_count} dependent objects were found: " # Append dependent objects to error message dependent_objects = [] - for obj in e.protected_objects: - if hasattr(obj, 'get_absolute_url'): - dependent_objects.append('{}'.format(obj.get_absolute_url(), escape(obj))) + for dependent in protected_objects[:50]: + if hasattr(dependent, 'get_absolute_url'): + dependent_objects.append(f'{escape(dependent)}') else: - dependent_objects.append(str(obj)) + dependent_objects.append(str(dependent)) err_message += ', '.join(dependent_objects) messages.error(request, mark_safe(err_message)) diff --git a/netbox/utilities/exceptions.py b/netbox/utilities/exceptions.py index 5032aacee..77a915d9c 100644 --- a/netbox/utilities/exceptions.py +++ b/netbox/utilities/exceptions.py @@ -1,5 +1,19 @@ +from rest_framework import status +from rest_framework.exceptions import APIException + + class AbortTransaction(Exception): """ A dummy exception used to trigger a database transaction rollback. """ pass + + +class RQWorkerNotRunningException(APIException): + """ + Indicates the temporary inability to enqueue a new task (e.g. custom script execution) because no RQ worker + processes are currently running. + """ + status_code = status.HTTP_503_SERVICE_UNAVAILABLE + default_detail = 'Unable to process request: RQ worker process not running.' + default_code = 'rq_worker_not_running' diff --git a/netbox/utilities/fields.py b/netbox/utilities/fields.py index 4eb19f539..a9b851def 100644 --- a/netbox/utilities/fields.py +++ b/netbox/utilities/fields.py @@ -68,6 +68,6 @@ class NaturalOrderingField(models.CharField): return ( self.name, 'utilities.fields.NaturalOrderingField', - ['target_field'], + [self.target_field], kwargs, ) diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index f628ca917..6305c0bba 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -1,4 +1,5 @@ import django_filters +from django_filters.constants import EMPTY_VALUES from copy import deepcopy from dcim.forms import MACAddressField from django import forms @@ -68,11 +69,10 @@ class TreeNodeMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter): """ Filters for a set of Models, including all descendant models within a Tree. Example: [,] """ - def get_filter_predicate(self, v): - # null value filtering + # Null value filtering if v is None: - return {self.field_name.replace('in', 'isnull'): True} + return {f"{self.field_name}__isnull": True} return super().get_filter_predicate(v) def filter(self, qs, value): @@ -84,7 +84,6 @@ class NullableCharFieldFilter(django_filters.CharFilter): """ Allow matching on null field values by passing a special string used to signify NULL. """ - def filter(self, qs, value): if value != settings.FILTERS_NULL_CHOICE_VALUE: return super().filter(qs, value) @@ -107,6 +106,36 @@ class TagFilter(django_filters.ModelMultipleChoiceFilter): super().__init__(*args, **kwargs) +class NumericArrayFilter(django_filters.NumberFilter): + """ + Filter based on the presence of an integer within an ArrayField. + """ + def filter(self, qs, value): + if value: + value = [value] + return super().filter(qs, value) + + +class ContentTypeFilter(django_filters.CharFilter): + """ + Allow specifying a ContentType by . (e.g. "dcim.site"). + """ + def filter(self, qs, value): + if value in EMPTY_VALUES: + return qs + + try: + app_label, model = value.lower().split('.') + except ValueError: + return qs.none() + return qs.filter( + **{ + f'{self.field_name}__app_label': app_label, + f'{self.field_name}__model': model + } + ) + + # # FilterSets # diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py deleted file mode 100644 index 840efdc4e..000000000 --- a/netbox/utilities/forms.py +++ /dev/null @@ -1,826 +0,0 @@ -import csv -import json -import re -from io import StringIO - -import django_filters -import yaml -from django import forms -from django.conf import settings -from django.contrib.postgres.forms.jsonb import JSONField as _JSONField -from django.core.exceptions import MultipleObjectsReturned -from django.db.models import Count -from django.forms import BoundField -from django.forms.models import fields_for_model -from django.urls import reverse - -from .choices import ColorChoices, unpack_grouped_choices -from .validators import EnhancedURLValidator - -NUMERIC_EXPANSION_PATTERN = r'\[((?:\d+[?:,-])+\d+)\]' -ALPHANUMERIC_EXPANSION_PATTERN = r'\[((?:[a-zA-Z0-9]+[?:,-])+[a-zA-Z0-9]+)\]' -IP4_EXPANSION_PATTERN = r'\[((?:[0-9]{1,3}[?:,-])+[0-9]{1,3})\]' -IP6_EXPANSION_PATTERN = r'\[((?:[0-9a-f]{1,4}[?:,-])+[0-9a-f]{1,4})\]' -BOOLEAN_WITH_BLANK_CHOICES = ( - ('', '---------'), - ('True', 'Yes'), - ('False', 'No'), -) - - -class InvalidJSONInput(str): - pass - - -def parse_numeric_range(string, base=10): - """ - Expand a numeric range (continuous or not) into a decimal or - hexadecimal list, as specified by the base parameter - '0-3,5' => [0, 1, 2, 3, 5] - '2,8-b,d,f' => [2, 8, 9, a, b, d, f] - """ - values = list() - for dash_range in string.split(','): - try: - begin, end = dash_range.split('-') - except ValueError: - begin, end = dash_range, dash_range - begin, end = int(begin.strip(), base=base), int(end.strip(), base=base) + 1 - values.extend(range(begin, end)) - return list(set(values)) - - -def parse_alphanumeric_range(string): - """ - Expand an alphanumeric range (continuous or not) into a list. - 'a-d,f' => [a, b, c, d, f] - '0-3,a-d' => [0, 1, 2, 3, a, b, c, d] - """ - values = [] - for dash_range in string.split(','): - try: - begin, end = dash_range.split('-') - vals = begin + end - # Break out of loop if there's an invalid pattern to return an error - if (not (vals.isdigit() or vals.isalpha())) or (vals.isalpha() and not (vals.isupper() or vals.islower())): - return [] - except ValueError: - begin, end = dash_range, dash_range - if begin.isdigit() and end.isdigit(): - for n in list(range(int(begin), int(end) + 1)): - values.append(n) - else: - # Value-based - if begin == end: - values.append(begin) - # Range-based - else: - # Not a valid range (more than a single character) - if not len(begin) == len(end) == 1: - raise forms.ValidationError('Range "{}" is invalid.'.format(dash_range)) - for n in list(range(ord(begin), ord(end) + 1)): - values.append(chr(n)) - return values - - -def expand_alphanumeric_pattern(string): - """ - Expand an alphabetic pattern into a list of strings. - """ - lead, pattern, remnant = re.split(ALPHANUMERIC_EXPANSION_PATTERN, string, maxsplit=1) - parsed_range = parse_alphanumeric_range(pattern) - for i in parsed_range: - if re.search(ALPHANUMERIC_EXPANSION_PATTERN, remnant): - for string in expand_alphanumeric_pattern(remnant): - yield "{}{}{}".format(lead, i, string) - else: - yield "{}{}{}".format(lead, i, remnant) - - -def expand_ipaddress_pattern(string, family): - """ - Expand an IP address pattern into a list of strings. Examples: - '192.0.2.[1,2,100-250]/24' => ['192.0.2.1/24', '192.0.2.2/24', '192.0.2.100/24' ... '192.0.2.250/24'] - '2001:db8:0:[0,fd-ff]::/64' => ['2001:db8:0:0::/64', '2001:db8:0:fd::/64', ... '2001:db8:0:ff::/64'] - """ - if family not in [4, 6]: - raise Exception("Invalid IP address family: {}".format(family)) - if family == 4: - regex = IP4_EXPANSION_PATTERN - base = 10 - else: - regex = IP6_EXPANSION_PATTERN - base = 16 - lead, pattern, remnant = re.split(regex, string, maxsplit=1) - parsed_range = parse_numeric_range(pattern, base) - for i in parsed_range: - if re.search(regex, remnant): - for string in expand_ipaddress_pattern(remnant, family): - yield ''.join([lead, format(i, 'x' if family == 6 else 'd'), string]) - else: - yield ''.join([lead, format(i, 'x' if family == 6 else 'd'), remnant]) - - -def add_blank_choice(choices): - """ - Add a blank choice to the beginning of a choices list. - """ - return ((None, '---------'),) + tuple(choices) - - -def form_from_model(model, fields): - """ - Return a Form class with the specified fields derived from a model. This is useful when we need a form to be used - for creating objects, but want to avoid the model's validation (e.g. for bulk create/edit functions). All fields - are marked as not required. - """ - form_fields = fields_for_model(model, fields=fields) - for field in form_fields.values(): - field.required = False - - return type('FormFromModel', (forms.Form,), form_fields) - - -# -# Widgets -# - -class SmallTextarea(forms.Textarea): - """ - Subclass used for rendering a smaller textarea element. - """ - pass - - -class SlugWidget(forms.TextInput): - """ - Subclass TextInput and add a slug regeneration button next to the form field. - """ - template_name = 'widgets/sluginput.html' - - -class ColorSelect(forms.Select): - """ - Extends the built-in Select widget to colorize each - This attribute can be used to reference the relevant API endpoint for a particular ContentType. - """ - option_template_name = 'widgets/select_contenttype.html' - - -class ArrayFieldSelectMultiple(SelectWithDisabled, forms.SelectMultiple): - """ - MultiSelect widget for a SimpleArrayField. Choices must be populated on the widget. - """ - def __init__(self, *args, **kwargs): - self.delimiter = kwargs.pop('delimiter', ',') - super().__init__(*args, **kwargs) - - def optgroups(self, name, value, attrs=None): - # Split the delimited string of values into a list - if value: - value = value[0].split(self.delimiter) - return super().optgroups(name, value, attrs) - - def value_from_datadict(self, data, files, name): - # Condense the list of selected choices into a delimited string - data = super().value_from_datadict(data, files, name) - return self.delimiter.join(data) - - -class APISelect(SelectWithDisabled): - """ - A select widget populated via an API call - - :param api_url: API endpoint URL. Required if not set automatically by the parent field. - :param display_field: (Optional) Field to display for child in selection list. Defaults to `name`. - :param value_field: (Optional) Field to use for the option value in selection list. Defaults to `id`. - :param disabled_indicator: (Optional) Mark option as disabled if this field equates true. - :param filter_for: (Optional) A dict of chained form fields for which this field is a filter. The key is the - name of the filter-for field (child field) and the value is the name of the query param filter. - :param conditional_query_params: (Optional) A dict of URL query params to append to the URL if the - condition is met. The condition is the dict key and is specified in the form `__`. - If the provided field value is selected for the given field, the URL query param will be appended to - the rendered URL. The value is the in the from `=`. This is useful in cases where - a particular field value dictates an additional API filter. - :param additional_query_params: Optional) A dict of query params to append to the API request. The key is the - name of the query param and the value if the query param's value. - :param null_option: If true, include the static null option in the selection list. - """ - def __init__( - self, - api_url=None, - display_field=None, - value_field=None, - disabled_indicator=None, - filter_for=None, - conditional_query_params=None, - additional_query_params=None, - null_option=False, - full=False, - *args, - **kwargs - ): - - super().__init__(*args, **kwargs) - - self.attrs['class'] = 'netbox-select2-api' - if api_url: - self.attrs['data-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/')) # Inject BASE_PATH - if full: - self.attrs['data-full'] = full - if display_field: - self.attrs['display-field'] = display_field - if value_field: - self.attrs['value-field'] = value_field - if disabled_indicator: - self.attrs['disabled-indicator'] = disabled_indicator - if filter_for: - for key, value in filter_for.items(): - self.add_filter_for(key, value) - if conditional_query_params: - for key, value in conditional_query_params.items(): - self.add_conditional_query_param(key, value) - if additional_query_params: - for key, value in additional_query_params.items(): - self.add_additional_query_param(key, value) - if null_option: - self.attrs['data-null-option'] = 1 - - def add_filter_for(self, name, value): - """ - Add details for an additional query param in the form of a data-filter-for-* attribute. - - :param name: The name of the query param - :param value: The value of the query param - """ - self.attrs['data-filter-for-{}'.format(name)] = value - - def add_additional_query_param(self, name, value): - """ - Add details for an additional query param in the form of a data-* JSON-encoded list attribute. - - :param name: The name of the query param - :param value: The value of the query param - """ - key = 'data-additional-query-param-{}'.format(name) - - values = json.loads(self.attrs.get(key, '[]')) - values.append(value) - - self.attrs[key] = json.dumps(values) - - def add_conditional_query_param(self, condition, value): - """ - Add details for a URL query strings to append to the URL if the condition is met. - The condition is specified in the form `__`. - - :param condition: The condition for the query param - :param value: The value of the query param - """ - self.attrs['data-conditional-query-param-{}'.format(condition)] = value - - -class APISelectMultiple(APISelect, forms.SelectMultiple): - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.attrs['data-multiple'] = 1 - - -class DatePicker(forms.TextInput): - """ - Date picker using Flatpickr. - """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.attrs['class'] = 'date-picker' - self.attrs['placeholder'] = 'YYYY-MM-DD' - - -class DateTimePicker(forms.TextInput): - """ - DateTime picker using Flatpickr. - """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.attrs['class'] = 'datetime-picker' - self.attrs['placeholder'] = 'YYYY-MM-DD hh:mm:ss' - - -class TimePicker(forms.TextInput): - """ - Time picker using Flatpickr. - """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.attrs['class'] = 'time-picker' - self.attrs['placeholder'] = 'hh:mm:ss' - - -# -# Form fields -# - -class CSVDataField(forms.CharField): - """ - A CharField (rendered as a Textarea) which accepts CSV-formatted data. It returns data as a two-tuple: The first - item is a dictionary of column headers, mapping field names to the attribute by which they match a related object - (where applicable). The second item is a list of dictionaries, each representing a discrete row of CSV data. - - :param from_form: The form from which the field derives its validation rules. - """ - widget = forms.Textarea - - def __init__(self, from_form, *args, **kwargs): - - form = from_form() - self.model = form.Meta.model - self.fields = form.fields - self.required_fields = [ - name for name, field in form.fields.items() if field.required - ] - - super().__init__(*args, **kwargs) - - self.strip = False - if not self.label: - self.label = '' - if not self.initial: - self.initial = ','.join(self.required_fields) + '\n' - if not self.help_text: - self.help_text = 'Enter the list of column headers followed by one line per record to be imported, using ' \ - 'commas to separate values. Multi-line data and values containing commas may be wrapped ' \ - 'in double quotes.' - - def to_python(self, value): - - records = [] - reader = csv.reader(StringIO(value.strip())) - - # Consume the first line of CSV data as column headers. Create a dictionary mapping each header to an optional - # "to" field specifying how the related object is being referenced. For example, importing a Device might use a - # `site.slug` header, to indicate the related site is being referenced by its slug. - headers = {} - for header in next(reader): - if '.' in header: - field, to_field = header.split('.', 1) - headers[field] = to_field - else: - headers[header] = None - - # Parse CSV rows into a list of dictionaries mapped from the column headers. - for i, row in enumerate(reader, start=1): - if len(row) != len(headers): - raise forms.ValidationError( - f"Row {i}: Expected {len(headers)} columns but found {len(row)}" - ) - row = [col.strip() for col in row] - record = dict(zip(headers.keys(), row)) - records.append(record) - - return headers, records - - def validate(self, value): - headers, records = value - - # Validate provided column headers - for field, to_field in headers.items(): - if field not in self.fields: - raise forms.ValidationError(f'Unexpected column header "{field}" found.') - if to_field and not hasattr(self.fields[field], 'to_field_name'): - raise forms.ValidationError(f'Column "{field}" is not a related object; cannot use dots') - if to_field and not hasattr(self.fields[field].queryset.model, to_field): - raise forms.ValidationError(f'Invalid related object attribute for column "{field}": {to_field}') - - # Validate required fields - for f in self.required_fields: - if f not in headers: - raise forms.ValidationError(f'Required column header "{f}" not found.') - - return value - - -class CSVChoiceField(forms.ChoiceField): - """ - Invert the provided set of choices to take the human-friendly label as input, and return the database value. - """ - def __init__(self, choices, *args, **kwargs): - super().__init__(choices=choices, *args, **kwargs) - self.choices = [(label, label) for value, label in unpack_grouped_choices(choices)] - self.choice_values = {label: value for value, label in unpack_grouped_choices(choices)} - - def clean(self, value): - value = super().clean(value) - if not value: - return '' - if value not in self.choice_values: - raise forms.ValidationError("Invalid choice: {}".format(value)) - return self.choice_values[value] - - -class CSVModelChoiceField(forms.ModelChoiceField): - """ - Provides additional validation for model choices entered as CSV data. - """ - default_error_messages = { - 'invalid_choice': 'Object not found.', - } - - def to_python(self, value): - try: - return super().to_python(value) - except MultipleObjectsReturned as e: - raise forms.ValidationError( - f'"{value}" is not a unique value for this field; multiple objects were found' - ) - - -class ExpandableNameField(forms.CharField): - """ - A field which allows for numeric range expansion - Example: 'Gi0/[1-3]' => ['Gi0/1', 'Gi0/2', 'Gi0/3'] - """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if not self.help_text: - self.help_text = """ - Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range - are not supported. Examples: -
          -
        • [ge,xe]-0/0/[0-9]
        • -
        • e[0-3][a-d,f]
        • -
        - """ - - def to_python(self, value): - if re.search(ALPHANUMERIC_EXPANSION_PATTERN, value): - return list(expand_alphanumeric_pattern(value)) - return [value] - - -class ExpandableIPAddressField(forms.CharField): - """ - A field which allows for expansion of IP address ranges - Example: '192.0.2.[1-254]/24' => ['192.0.2.1/24', '192.0.2.2/24', '192.0.2.3/24' ... '192.0.2.254/24'] - """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if not self.help_text: - self.help_text = 'Specify a numeric range to create multiple IPs.
        '\ - 'Example: 192.0.2.[1,5,100-254]/24' - - def to_python(self, value): - # Hackish address family detection but it's all we have to work with - if '.' in value and re.search(IP4_EXPANSION_PATTERN, value): - return list(expand_ipaddress_pattern(value, 4)) - elif ':' in value and re.search(IP6_EXPANSION_PATTERN, value): - return list(expand_ipaddress_pattern(value, 6)) - return [value] - - -class CommentField(forms.CharField): - """ - A textarea with support for Markdown rendering. Exists mostly just to add a standard help_text. - """ - widget = forms.Textarea - default_label = '' - # TODO: Port Markdown cheat sheet to internal documentation - default_helptext = ' '\ - ''\ - 'Markdown syntax is supported' - - def __init__(self, *args, **kwargs): - required = kwargs.pop('required', False) - label = kwargs.pop('label', self.default_label) - help_text = kwargs.pop('help_text', self.default_helptext) - super().__init__(required=required, label=label, help_text=help_text, *args, **kwargs) - - -class SlugField(forms.SlugField): - """ - Extend the built-in SlugField to automatically populate from a field called `name` unless otherwise specified. - """ - def __init__(self, slug_source='name', *args, **kwargs): - label = kwargs.pop('label', "Slug") - help_text = kwargs.pop('help_text', "URL-friendly unique shorthand") - widget = kwargs.pop('widget', SlugWidget) - super().__init__(label=label, help_text=help_text, widget=widget, *args, **kwargs) - self.widget.attrs['slug-source'] = slug_source - - -class TagFilterField(forms.MultipleChoiceField): - """ - A filter field for the tags of a model. Only the tags used by a model are displayed. - - :param model: The model of the filter - """ - widget = StaticSelect2Multiple - - def __init__(self, model, *args, **kwargs): - def get_choices(): - tags = model.tags.annotate(count=Count('extras_taggeditem_items')).order_by('name') - return [(str(tag.slug), '{} ({})'.format(tag.name, tag.count)) for tag in tags] - - # Choices are fetched each time the form is initialized - super().__init__(label='Tags', choices=get_choices, required=False, *args, **kwargs) - - -class DynamicModelChoiceMixin: - filter = django_filters.ModelChoiceFilter - widget = APISelect - - def _get_initial_value(self, initial_data, field_name): - return initial_data.get(field_name) - - def get_bound_field(self, form, field_name): - bound_field = BoundField(form, self, field_name) - - # Override initial() to allow passing multiple values - bound_field.initial = self._get_initial_value(form.initial, field_name) - - # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options - # will be populated on-demand via the APISelect widget. - data = bound_field.value() - if data: - filter = self.filter(field_name=self.to_field_name or 'pk', queryset=self.queryset) - self.queryset = filter.filter(self.queryset, data) - else: - self.queryset = self.queryset.none() - - # Set the data URL on the APISelect widget (if not already set) - widget = bound_field.field.widget - if not widget.attrs.get('data-url'): - app_label = self.queryset.model._meta.app_label - model_name = self.queryset.model._meta.model_name - data_url = reverse('{}-api:{}-list'.format(app_label, model_name)) - widget.attrs['data-url'] = data_url - - return bound_field - - -class DynamicModelChoiceField(DynamicModelChoiceMixin, forms.ModelChoiceField): - """ - Override get_bound_field() to avoid pre-populating field choices with a SQL query. The field will be - rendered only with choices set via bound data. Choices are populated on-demand via the APISelect widget. - """ - pass - - -class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultipleChoiceField): - """ - A multiple-choice version of DynamicModelChoiceField. - """ - filter = django_filters.ModelMultipleChoiceFilter - widget = APISelectMultiple - - def _get_initial_value(self, initial_data, field_name): - # If a QueryDict has been passed as initial form data, get *all* listed values - if hasattr(initial_data, 'getlist'): - return initial_data.getlist(field_name) - return initial_data.get(field_name) - - -class LaxURLField(forms.URLField): - """ - Modifies Django's built-in URLField in two ways: - 1) Allow any valid scheme per RFC 3986 section 3.1 - 2) Remove the requirement for fully-qualified domain names (e.g. http://myserver/ is valid) - """ - default_validators = [EnhancedURLValidator()] - - -class JSONField(_JSONField): - """ - Custom wrapper around Django's built-in JSONField to avoid presenting "null" as the default text. - """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if not self.help_text: - self.help_text = 'Enter context data in JSON format.' - self.widget.attrs['placeholder'] = '' - - def prepare_value(self, value): - if isinstance(value, InvalidJSONInput): - return value - if value is None: - return '' - return json.dumps(value, sort_keys=True, indent=4) - - -# -# Forms -# - -class BootstrapMixin(forms.BaseForm): - """ - Add the base Bootstrap CSS classes to form elements. - """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - exempt_widgets = [ - forms.CheckboxInput, - forms.ClearableFileInput, - forms.FileInput, - forms.RadioSelect - ] - - for field_name, field in self.fields.items(): - if field.widget.__class__ not in exempt_widgets: - css = field.widget.attrs.get('class', '') - field.widget.attrs['class'] = ' '.join([css, 'form-control']).strip() - if field.required and not isinstance(field.widget, forms.FileInput): - field.widget.attrs['required'] = 'required' - if 'placeholder' not in field.widget.attrs: - field.widget.attrs['placeholder'] = field.label - - -class ReturnURLForm(forms.Form): - """ - Provides a hidden return URL field to control where the user is directed after the form is submitted. - """ - return_url = forms.CharField(required=False, widget=forms.HiddenInput()) - - -class ConfirmationForm(BootstrapMixin, ReturnURLForm): - """ - A generic confirmation form. The form is not valid unless the confirm field is checked. - """ - confirm = forms.BooleanField(required=True, widget=forms.HiddenInput(), initial=True) - - -class BulkEditForm(forms.Form): - """ - Base form for editing multiple objects in bulk - """ - def __init__(self, model, *args, **kwargs): - super().__init__(*args, **kwargs) - self.model = model - self.nullable_fields = [] - - # Copy any nullable fields defined in Meta - if hasattr(self.Meta, 'nullable_fields'): - self.nullable_fields = self.Meta.nullable_fields - - -class CSVModelForm(forms.ModelForm): - """ - ModelForm used for the import of objects in CSV format. - """ - def __init__(self, *args, headers=None, **kwargs): - super().__init__(*args, **kwargs) - - # Modify the model form to accommodate any customized to_field_name properties - if headers: - for field, to_field in headers.items(): - if to_field is not None: - self.fields[field].to_field_name = to_field - - -class ImportForm(BootstrapMixin, forms.Form): - """ - Generic form for creating an object from JSON/YAML data - """ - data = forms.CharField( - widget=forms.Textarea, - help_text="Enter object data in JSON or YAML format. Note: Only a single object/document is supported." - ) - format = forms.ChoiceField( - choices=( - ('json', 'JSON'), - ('yaml', 'YAML') - ), - initial='yaml' - ) - - def clean(self): - - data = self.cleaned_data['data'] - format = self.cleaned_data['format'] - - # Process JSON/YAML data - if format == 'json': - try: - self.cleaned_data['data'] = json.loads(data) - # Check for multiple JSON objects - if type(self.cleaned_data['data']) is not dict: - raise forms.ValidationError({ - 'data': "Import is limited to one object at a time." - }) - except json.decoder.JSONDecodeError as err: - raise forms.ValidationError({ - 'data': "Invalid JSON data: {}".format(err) - }) - else: - # Check for multiple YAML documents - if '\n---' in data: - raise forms.ValidationError({ - 'data': "Import is limited to one object at a time." - }) - try: - self.cleaned_data['data'] = yaml.load(data, Loader=yaml.SafeLoader) - except yaml.error.YAMLError as err: - raise forms.ValidationError({ - 'data': "Invalid YAML data: {}".format(err) - }) - - -class TableConfigForm(BootstrapMixin, forms.Form): - """ - Form for configuring user's table preferences. - """ - columns = forms.MultipleChoiceField( - choices=[], - widget=forms.SelectMultiple( - attrs={'size': 10} - ), - help_text="Use the buttons below to arrange columns in the desired order, then select all columns to display." - ) - - def __init__(self, table, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Initialize columns field based on table attributes - self.fields['columns'].choices = table.configurable_columns - self.fields['columns'].initial = table.visible_columns diff --git a/netbox/utilities/forms/__init__.py b/netbox/utilities/forms/__init__.py new file mode 100644 index 000000000..ce958a99e --- /dev/null +++ b/netbox/utilities/forms/__init__.py @@ -0,0 +1,5 @@ +from .constants import * +from .fields import * +from .forms import * +from .utils import * +from .widgets import * diff --git a/netbox/utilities/forms/constants.py b/netbox/utilities/forms/constants.py new file mode 100644 index 000000000..624ad5dac --- /dev/null +++ b/netbox/utilities/forms/constants.py @@ -0,0 +1,14 @@ +# String expansion patterns +NUMERIC_EXPANSION_PATTERN = r'\[((?:\d+[?:,-])+\d+)\]' +ALPHANUMERIC_EXPANSION_PATTERN = r'\[((?:[a-zA-Z0-9]+[?:,-])+[a-zA-Z0-9]+)\]' + +# IP address expansion patterns +IP4_EXPANSION_PATTERN = r'\[((?:[0-9]{1,3}[?:,-])+[0-9]{1,3})\]' +IP6_EXPANSION_PATTERN = r'\[((?:[0-9a-f]{1,4}[?:,-])+[0-9a-f]{1,4})\]' + +# Boolean widget choices +BOOLEAN_WITH_BLANK_CHOICES = ( + ('', '---------'), + ('True', 'Yes'), + ('False', 'No'), +) diff --git a/netbox/utilities/forms/fields.py b/netbox/utilities/forms/fields.py new file mode 100644 index 000000000..a5b76fd8d --- /dev/null +++ b/netbox/utilities/forms/fields.py @@ -0,0 +1,392 @@ +import csv +import json +import re +from io import StringIO + +import django_filters +from django import forms +from django.forms.fields import JSONField as _JSONField, InvalidJSONInput +from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist +from django.db.models import Count +from django.forms import BoundField +from django.urls import reverse + +from utilities.choices import unpack_grouped_choices +from utilities.validators import EnhancedURLValidator +from . import widgets +from .constants import * +from .utils import expand_alphanumeric_pattern, expand_ipaddress_pattern + +__all__ = ( + 'CommentField', + 'CSVChoiceField', + 'CSVContentTypeField', + 'CSVDataField', + 'CSVModelChoiceField', + 'DynamicModelChoiceField', + 'DynamicModelMultipleChoiceField', + 'ExpandableIPAddressField', + 'ExpandableNameField', + 'JSONField', + 'LaxURLField', + 'SlugField', + 'TagFilterField', +) + + +class CSVDataField(forms.CharField): + """ + A CharField (rendered as a Textarea) which accepts CSV-formatted data. It returns data as a two-tuple: The first + item is a dictionary of column headers, mapping field names to the attribute by which they match a related object + (where applicable). The second item is a list of dictionaries, each representing a discrete row of CSV data. + + :param from_form: The form from which the field derives its validation rules. + """ + widget = forms.Textarea + + def __init__(self, from_form, *args, **kwargs): + + form = from_form() + self.model = form.Meta.model + self.fields = form.fields + self.required_fields = [ + name for name, field in form.fields.items() if field.required + ] + + super().__init__(*args, **kwargs) + + self.strip = False + if not self.label: + self.label = '' + if not self.initial: + self.initial = ','.join(self.required_fields) + '\n' + if not self.help_text: + self.help_text = 'Enter the list of column headers followed by one line per record to be imported, using ' \ + 'commas to separate values. Multi-line data and values containing commas may be wrapped ' \ + 'in double quotes.' + + def to_python(self, value): + + records = [] + reader = csv.reader(StringIO(value.strip())) + + # Consume the first line of CSV data as column headers. Create a dictionary mapping each header to an optional + # "to" field specifying how the related object is being referenced. For example, importing a Device might use a + # `site.slug` header, to indicate the related site is being referenced by its slug. + headers = {} + for header in next(reader): + if '.' in header: + field, to_field = header.split('.', 1) + headers[field] = to_field + else: + headers[header] = None + + # Parse CSV rows into a list of dictionaries mapped from the column headers. + for i, row in enumerate(reader, start=1): + if len(row) != len(headers): + raise forms.ValidationError( + f"Row {i}: Expected {len(headers)} columns but found {len(row)}" + ) + row = [col.strip() for col in row] + record = dict(zip(headers.keys(), row)) + records.append(record) + + return headers, records + + def validate(self, value): + headers, records = value + + # Validate provided column headers + for field, to_field in headers.items(): + if field not in self.fields: + raise forms.ValidationError(f'Unexpected column header "{field}" found.') + if to_field and not hasattr(self.fields[field], 'to_field_name'): + raise forms.ValidationError(f'Column "{field}" is not a related object; cannot use dots') + if to_field and not hasattr(self.fields[field].queryset.model, to_field): + raise forms.ValidationError(f'Invalid related object attribute for column "{field}": {to_field}') + + # Validate required fields + for f in self.required_fields: + if f not in headers: + raise forms.ValidationError(f'Required column header "{f}" not found.') + + return value + + +class CSVChoiceField(forms.ChoiceField): + """ + Invert the provided set of choices to take the human-friendly label as input, and return the database value. + """ + STATIC_CHOICES = True + + def __init__(self, *, choices=(), **kwargs): + super().__init__(choices=choices, **kwargs) + self.choices = unpack_grouped_choices(choices) + + +class CSVModelChoiceField(forms.ModelChoiceField): + """ + Provides additional validation for model choices entered as CSV data. + """ + default_error_messages = { + 'invalid_choice': 'Object not found.', + } + + def to_python(self, value): + try: + return super().to_python(value) + except MultipleObjectsReturned: + raise forms.ValidationError( + f'"{value}" is not a unique value for this field; multiple objects were found' + ) + + +class CSVContentTypeField(CSVModelChoiceField): + """ + Reference a ContentType in the form . + """ + STATIC_CHOICES = True + + def prepare_value(self, value): + return f'{value.app_label}.{value.model}' + + def to_python(self, value): + try: + app_label, model = value.split('.') + except ValueError: + raise forms.ValidationError(f'Object type must be specified as "."') + try: + return self.queryset.get(app_label=app_label, model=model) + except ObjectDoesNotExist: + raise forms.ValidationError(f'Invalid object type') + + +class ExpandableNameField(forms.CharField): + """ + A field which allows for numeric range expansion + Example: 'Gi0/[1-3]' => ['Gi0/1', 'Gi0/2', 'Gi0/3'] + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.help_text: + self.help_text = """ + Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range + are not supported. Examples: +
          +
        • [ge,xe]-0/0/[0-9]
        • +
        • e[0-3][a-d,f]
        • +
        + """ + + def to_python(self, value): + if not value: + return '' + if re.search(ALPHANUMERIC_EXPANSION_PATTERN, value): + return list(expand_alphanumeric_pattern(value)) + return [value] + + +class ExpandableIPAddressField(forms.CharField): + """ + A field which allows for expansion of IP address ranges + Example: '192.0.2.[1-254]/24' => ['192.0.2.1/24', '192.0.2.2/24', '192.0.2.3/24' ... '192.0.2.254/24'] + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.help_text: + self.help_text = 'Specify a numeric range to create multiple IPs.
        '\ + 'Example: 192.0.2.[1,5,100-254]/24' + + def to_python(self, value): + # Hackish address family detection but it's all we have to work with + if '.' in value and re.search(IP4_EXPANSION_PATTERN, value): + return list(expand_ipaddress_pattern(value, 4)) + elif ':' in value and re.search(IP6_EXPANSION_PATTERN, value): + return list(expand_ipaddress_pattern(value, 6)) + return [value] + + +class CommentField(forms.CharField): + """ + A textarea with support for Markdown rendering. Exists mostly just to add a standard help_text. + """ + widget = forms.Textarea + default_label = '' + # TODO: Port Markdown cheat sheet to internal documentation + default_helptext = ' '\ + ''\ + 'Markdown syntax is supported' + + def __init__(self, *args, **kwargs): + required = kwargs.pop('required', False) + label = kwargs.pop('label', self.default_label) + help_text = kwargs.pop('help_text', self.default_helptext) + super().__init__(required=required, label=label, help_text=help_text, *args, **kwargs) + + +class SlugField(forms.SlugField): + """ + Extend the built-in SlugField to automatically populate from a field called `name` unless otherwise specified. + """ + def __init__(self, slug_source='name', *args, **kwargs): + label = kwargs.pop('label', "Slug") + help_text = kwargs.pop('help_text', "URL-friendly unique shorthand") + widget = kwargs.pop('widget', widgets.SlugWidget) + super().__init__(label=label, help_text=help_text, widget=widget, *args, **kwargs) + self.widget.attrs['slug-source'] = slug_source + + +class TagFilterField(forms.MultipleChoiceField): + """ + A filter field for the tags of a model. Only the tags used by a model are displayed. + + :param model: The model of the filter + """ + widget = widgets.StaticSelect2Multiple + + def __init__(self, model, *args, **kwargs): + def get_choices(): + tags = model.tags.annotate( + count=Count('extras_taggeditem_items') + ).order_by('name') + return [ + (str(tag.slug), '{} ({})'.format(tag.name, tag.count)) for tag in tags + ] + + # Choices are fetched each time the form is initialized + super().__init__(label='Tags', choices=get_choices, required=False, *args, **kwargs) + + +class DynamicModelChoiceMixin: + """ + :param display_field: The name of the attribute of an API response object to display in the selection list + :param query_params: A dictionary of additional key/value pairs to attach to the API request + :param initial_params: A dictionary of child field references to use for selecting a parent field's initial value + :param null_option: The string used to represent a null selection (if any) + :param disabled_indicator: The name of the field which, if populated, will disable selection of the + choice (optional) + :param brief_mode: Use the "brief" format (?brief=true) when making API requests (default) + """ + filter = django_filters.ModelChoiceFilter + widget = widgets.APISelect + + def __init__(self, display_field='name', query_params=None, initial_params=None, null_option=None, + disabled_indicator=None, brief_mode=True, *args, **kwargs): + self.display_field = display_field + self.query_params = query_params or {} + self.initial_params = initial_params or {} + self.null_option = null_option + self.disabled_indicator = disabled_indicator + self.brief_mode = brief_mode + + # to_field_name is set by ModelChoiceField.__init__(), but we need to set it early for reference + # by widget_attrs() + self.to_field_name = kwargs.get('to_field_name') + + super().__init__(*args, **kwargs) + + def widget_attrs(self, widget): + attrs = { + 'display-field': self.display_field, + } + + # Set value-field attribute if the field specifies to_field_name + if self.to_field_name: + attrs['value-field'] = self.to_field_name + + # Set the string used to represent a null option + if self.null_option is not None: + attrs['data-null-option'] = self.null_option + + # Set the disabled indicator, if any + if self.disabled_indicator is not None: + attrs['disabled-indicator'] = self.disabled_indicator + + # Toggle brief mode + if not self.brief_mode: + attrs['data-full'] = 'true' + + # Attach any static query parameters + for key, value in self.query_params.items(): + widget.add_query_param(key, value) + + return attrs + + def get_bound_field(self, form, field_name): + bound_field = BoundField(form, self, field_name) + + # Set initial value based on prescribed child fields (if not already set) + if not self.initial and self.initial_params: + filter_kwargs = {} + for kwarg, child_field in self.initial_params.items(): + value = form.initial.get(child_field.lstrip('$')) + if value: + filter_kwargs[kwarg] = value + if filter_kwargs: + self.initial = self.queryset.filter(**filter_kwargs).first() + + # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options + # will be populated on-demand via the APISelect widget. + data = bound_field.value() + if data: + field_name = getattr(self, 'to_field_name') or 'pk' + filter = self.filter(field_name=field_name) + try: + self.queryset = filter.filter(self.queryset, data) + except TypeError: + # Catch any error caused by invalid initial data passed from the user + self.queryset = self.queryset.none() + else: + self.queryset = self.queryset.none() + + # Set the data URL on the APISelect widget (if not already set) + widget = bound_field.field.widget + if not widget.attrs.get('data-url'): + app_label = self.queryset.model._meta.app_label + model_name = self.queryset.model._meta.model_name + data_url = reverse('{}-api:{}-list'.format(app_label, model_name)) + widget.attrs['data-url'] = data_url + + return bound_field + + +class DynamicModelChoiceField(DynamicModelChoiceMixin, forms.ModelChoiceField): + """ + Override get_bound_field() to avoid pre-populating field choices with a SQL query. The field will be + rendered only with choices set via bound data. Choices are populated on-demand via the APISelect widget. + """ + pass + + +class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultipleChoiceField): + """ + A multiple-choice version of DynamicModelChoiceField. + """ + filter = django_filters.ModelMultipleChoiceFilter + widget = widgets.APISelectMultiple + + +class LaxURLField(forms.URLField): + """ + Modifies Django's built-in URLField to remove the requirement for fully-qualified domain names + (e.g. http://myserver/ is valid) + """ + default_validators = [EnhancedURLValidator()] + + +class JSONField(_JSONField): + """ + Custom wrapper around Django's built-in JSONField to avoid presenting "null" as the default text. + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.help_text: + self.help_text = 'Enter context data in JSON format.' + self.widget.attrs['placeholder'] = '' + + def prepare_value(self, value): + if isinstance(value, InvalidJSONInput): + return value + if value is None: + return '' + return json.dumps(value, sort_keys=True, indent=4) diff --git a/netbox/utilities/forms/forms.py b/netbox/utilities/forms/forms.py new file mode 100644 index 000000000..e674afdf7 --- /dev/null +++ b/netbox/utilities/forms/forms.py @@ -0,0 +1,184 @@ +import json +import re + +import yaml +from django import forms + + +__all__ = ( + 'BootstrapMixin', + 'BulkEditForm', + 'BulkRenameForm', + 'ConfirmationForm', + 'CSVModelForm', + 'ImportForm', + 'ReturnURLForm', + 'TableConfigForm', +) + + +class BootstrapMixin(forms.BaseForm): + """ + Add the base Bootstrap CSS classes to form elements. + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + exempt_widgets = [ + forms.CheckboxInput, + forms.ClearableFileInput, + forms.FileInput, + forms.RadioSelect + ] + + for field_name, field in self.fields.items(): + if field.widget.__class__ not in exempt_widgets: + css = field.widget.attrs.get('class', '') + field.widget.attrs['class'] = ' '.join([css, 'form-control']).strip() + if field.required and not isinstance(field.widget, forms.FileInput): + field.widget.attrs['required'] = 'required' + if 'placeholder' not in field.widget.attrs: + field.widget.attrs['placeholder'] = field.label + + +class ReturnURLForm(forms.Form): + """ + Provides a hidden return URL field to control where the user is directed after the form is submitted. + """ + return_url = forms.CharField(required=False, widget=forms.HiddenInput()) + + +class ConfirmationForm(BootstrapMixin, ReturnURLForm): + """ + A generic confirmation form. The form is not valid unless the confirm field is checked. + """ + confirm = forms.BooleanField(required=True, widget=forms.HiddenInput(), initial=True) + + +class BulkEditForm(forms.Form): + """ + Base form for editing multiple objects in bulk + """ + def __init__(self, model, *args, **kwargs): + super().__init__(*args, **kwargs) + self.model = model + self.nullable_fields = [] + + # Copy any nullable fields defined in Meta + if hasattr(self.Meta, 'nullable_fields'): + self.nullable_fields = self.Meta.nullable_fields + + +class BulkRenameForm(forms.Form): + """ + An extendable form to be used for renaming objects in bulk. + """ + find = forms.CharField() + replace = forms.CharField() + use_regex = forms.BooleanField( + required=False, + initial=True, + label='Use regular expressions' + ) + + def clean(self): + super().clean() + + # 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" + }) + + +class CSVModelForm(forms.ModelForm): + """ + ModelForm used for the import of objects in CSV format. + """ + def __init__(self, *args, headers=None, **kwargs): + super().__init__(*args, **kwargs) + + # Modify the model form to accommodate any customized to_field_name properties + if headers: + for field, to_field in headers.items(): + if to_field is not None: + self.fields[field].to_field_name = to_field + + +class ImportForm(BootstrapMixin, forms.Form): + """ + Generic form for creating an object from JSON/YAML data + """ + data = forms.CharField( + widget=forms.Textarea, + help_text="Enter object data in JSON or YAML format. Note: Only a single object/document is supported." + ) + format = forms.ChoiceField( + choices=( + ('json', 'JSON'), + ('yaml', 'YAML') + ), + initial='yaml' + ) + + def clean(self): + super().clean() + + data = self.cleaned_data['data'] + format = self.cleaned_data['format'] + + # Process JSON/YAML data + if format == 'json': + try: + self.cleaned_data['data'] = json.loads(data) + # Check for multiple JSON objects + if type(self.cleaned_data['data']) is not dict: + raise forms.ValidationError({ + 'data': "Import is limited to one object at a time." + }) + except json.decoder.JSONDecodeError as err: + raise forms.ValidationError({ + 'data': "Invalid JSON data: {}".format(err) + }) + else: + # Check for multiple YAML documents + if '\n---' in data: + raise forms.ValidationError({ + 'data': "Import is limited to one object at a time." + }) + try: + self.cleaned_data['data'] = yaml.load(data, Loader=yaml.SafeLoader) + except yaml.error.YAMLError as err: + raise forms.ValidationError({ + 'data': "Invalid YAML data: {}".format(err) + }) + + +class TableConfigForm(BootstrapMixin, forms.Form): + """ + Form for configuring user's table preferences. + """ + columns = forms.MultipleChoiceField( + choices=[], + required=False, + widget=forms.SelectMultiple( + attrs={'size': 10} + ), + help_text="Use the buttons below to arrange columns in the desired order, then select all columns to display." + ) + + def __init__(self, table, *args, **kwargs): + self.table = table + + super().__init__(*args, **kwargs) + + # Initialize columns field based on table attributes + self.fields['columns'].choices = table.configurable_columns + self.fields['columns'].initial = table.visible_columns + + @property + def table_name(self): + return self.table.__class__.__name__ diff --git a/netbox/utilities/forms/utils.py b/netbox/utilities/forms/utils.py new file mode 100644 index 000000000..dc001be1a --- /dev/null +++ b/netbox/utilities/forms/utils.py @@ -0,0 +1,136 @@ +import re + +from django import forms +from django.forms.models import fields_for_model + +from utilities.querysets import RestrictedQuerySet +from .constants import * + +__all__ = ( + 'add_blank_choice', + 'expand_alphanumeric_pattern', + 'expand_ipaddress_pattern', + 'form_from_model', + 'parse_alphanumeric_range', + 'parse_numeric_range', + 'restrict_form_fields', +) + + +def parse_numeric_range(string, base=10): + """ + Expand a numeric range (continuous or not) into a decimal or + hexadecimal list, as specified by the base parameter + '0-3,5' => [0, 1, 2, 3, 5] + '2,8-b,d,f' => [2, 8, 9, a, b, d, f] + """ + values = list() + for dash_range in string.split(','): + try: + begin, end = dash_range.split('-') + except ValueError: + begin, end = dash_range, dash_range + begin, end = int(begin.strip(), base=base), int(end.strip(), base=base) + 1 + values.extend(range(begin, end)) + return list(set(values)) + + +def parse_alphanumeric_range(string): + """ + Expand an alphanumeric range (continuous or not) into a list. + 'a-d,f' => [a, b, c, d, f] + '0-3,a-d' => [0, 1, 2, 3, a, b, c, d] + """ + values = [] + for dash_range in string.split(','): + try: + begin, end = dash_range.split('-') + vals = begin + end + # Break out of loop if there's an invalid pattern to return an error + if (not (vals.isdigit() or vals.isalpha())) or (vals.isalpha() and not (vals.isupper() or vals.islower())): + return [] + except ValueError: + begin, end = dash_range, dash_range + if begin.isdigit() and end.isdigit(): + for n in list(range(int(begin), int(end) + 1)): + values.append(n) + else: + # Value-based + if begin == end: + values.append(begin) + # Range-based + else: + # Not a valid range (more than a single character) + if not len(begin) == len(end) == 1: + raise forms.ValidationError('Range "{}" is invalid.'.format(dash_range)) + for n in list(range(ord(begin), ord(end) + 1)): + values.append(chr(n)) + return values + + +def expand_alphanumeric_pattern(string): + """ + Expand an alphabetic pattern into a list of strings. + """ + lead, pattern, remnant = re.split(ALPHANUMERIC_EXPANSION_PATTERN, string, maxsplit=1) + parsed_range = parse_alphanumeric_range(pattern) + for i in parsed_range: + if re.search(ALPHANUMERIC_EXPANSION_PATTERN, remnant): + for string in expand_alphanumeric_pattern(remnant): + yield "{}{}{}".format(lead, i, string) + else: + yield "{}{}{}".format(lead, i, remnant) + + +def expand_ipaddress_pattern(string, family): + """ + Expand an IP address pattern into a list of strings. Examples: + '192.0.2.[1,2,100-250]/24' => ['192.0.2.1/24', '192.0.2.2/24', '192.0.2.100/24' ... '192.0.2.250/24'] + '2001:db8:0:[0,fd-ff]::/64' => ['2001:db8:0:0::/64', '2001:db8:0:fd::/64', ... '2001:db8:0:ff::/64'] + """ + if family not in [4, 6]: + raise Exception("Invalid IP address family: {}".format(family)) + if family == 4: + regex = IP4_EXPANSION_PATTERN + base = 10 + else: + regex = IP6_EXPANSION_PATTERN + base = 16 + lead, pattern, remnant = re.split(regex, string, maxsplit=1) + parsed_range = parse_numeric_range(pattern, base) + for i in parsed_range: + if re.search(regex, remnant): + for string in expand_ipaddress_pattern(remnant, family): + yield ''.join([lead, format(i, 'x' if family == 6 else 'd'), string]) + else: + yield ''.join([lead, format(i, 'x' if family == 6 else 'd'), remnant]) + + +def add_blank_choice(choices): + """ + Add a blank choice to the beginning of a choices list. + """ + return ((None, '---------'),) + tuple(choices) + + +def form_from_model(model, fields): + """ + Return a Form class with the specified fields derived from a model. This is useful when we need a form to be used + for creating objects, but want to avoid the model's validation (e.g. for bulk create/edit functions). All fields + are marked as not required. + """ + form_fields = fields_for_model(model, fields=fields) + for field in form_fields.values(): + field.required = False + + return type('FormFromModel', (forms.Form,), form_fields) + + +def restrict_form_fields(form, user, action='view'): + """ + Restrict all form fields which reference a RestrictedQuerySet. This ensures that users see only permitted objects + as available choices. + """ + for field in form.fields.values(): + if hasattr(field, 'queryset') and issubclass(field.queryset.__class__, RestrictedQuerySet): + field.queryset = field.queryset.restrict(user, action) diff --git a/netbox/utilities/forms/widgets.py b/netbox/utilities/forms/widgets.py new file mode 100644 index 000000000..cd6fb0fbb --- /dev/null +++ b/netbox/utilities/forms/widgets.py @@ -0,0 +1,187 @@ +import json + +from django import forms +from django.conf import settings +from django.contrib.postgres.forms import SimpleArrayField + +from utilities.choices import ColorChoices +from .utils import add_blank_choice, parse_numeric_range + +__all__ = ( + 'APISelect', + 'APISelectMultiple', + 'BulkEditNullBooleanSelect', + 'ColorSelect', + 'ContentTypeSelect', + 'DatePicker', + 'DateTimePicker', + 'NumericArrayField', + 'SelectWithDisabled', + 'SelectWithPK', + 'SlugWidget', + 'SmallTextarea', + 'StaticSelect2', + 'StaticSelect2Multiple', + 'TimePicker', +) + + +class SmallTextarea(forms.Textarea): + """ + Subclass used for rendering a smaller textarea element. + """ + pass + + +class SlugWidget(forms.TextInput): + """ + Subclass TextInput and add a slug regeneration button next to the form field. + """ + template_name = 'widgets/sluginput.html' + + +class ColorSelect(forms.Select): + """ + Extends the built-in Select widget to colorize each