Просмотр исходного кода

Merge branch 'feature' into issue_9536

Jeremy Stretch 3 лет назад
Родитель
Сommit
cfb9605e9b
100 измененных файлов с 2160 добавлено и 714 удалено
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 5 2
      .github/workflows/stale.yml
  4. 3 3
      CONTRIBUTING.md
  5. 11 2
      base_requirements.txt
  6. 25 0
      docs/configuration/optional-settings.md
  7. 1 1
      docs/models/circuits/circuit.md
  8. 4 0
      docs/models/dcim/interface.md
  9. 1 2
      docs/models/dcim/location.md
  10. 2 0
      docs/models/extras/configcontext.md
  11. 1 1
      docs/models/users/token.md
  12. 1 1
      docs/models/virtualization/cluster.md
  13. 1 1
      docs/models/virtualization/virtualmachine.md
  14. 2 1
      docs/plugins/development/tables.md
  15. 353 0
      docs/reference/markdown.md
  16. 64 0
      docs/release-notes/version-3.2.md
  17. 63 0
      docs/release-notes/version-3.3.md
  18. 20 0
      docs/rest-api/filtering.md
  19. 1 0
      mkdocs.yml
  20. 3 3
      netbox/circuits/api/serializers.py
  21. 1 1
      netbox/circuits/filtersets.py
  22. 12 2
      netbox/circuits/forms/bulk_edit.py
  23. 2 1
      netbox/circuits/forms/bulk_import.py
  24. 12 4
      netbox/circuits/forms/filtersets.py
  25. 5 3
      netbox/circuits/forms/models.py
  26. 18 0
      netbox/circuits/migrations/0036_circuit_termination_date.py
  27. 7 2
      netbox/circuits/models/circuits.py
  28. 1 1
      netbox/circuits/tables/circuits.py
  29. 10 6
      netbox/circuits/tests/test_filtersets.py
  30. 1 0
      netbox/circuits/tests/test_views.py
  31. 35 9
      netbox/dcim/api/serializers.py
  32. 2 0
      netbox/dcim/api/views.py
  33. 78 0
      netbox/dcim/choices.py
  34. 2 1
      netbox/dcim/constants.py
  35. 14 4
      netbox/dcim/filtersets.py
  36. 5 2
      netbox/dcim/forms/bulk_create.py
  37. 45 7
      netbox/dcim/forms/bulk_edit.py
  38. 18 4
      netbox/dcim/forms/bulk_import.py
  39. 23 10
      netbox/dcim/forms/filtersets.py
  40. 13 6
      netbox/dcim/forms/models.py
  41. 2 0
      netbox/dcim/forms/object_create.py
  42. 6 0
      netbox/dcim/graphql/types.py
  43. 23 0
      netbox/dcim/migrations/0154_half_height_rack_units.py
  44. 23 0
      netbox/dcim/migrations/0155_interface_poe_mode_type.py
  45. 18 0
      netbox/dcim/migrations/0156_location_status.py
  46. 31 1
      netbox/dcim/models/device_components.py
  47. 13 5
      netbox/dcim/models/devices.py
  48. 37 29
      netbox/dcim/models/racks.py
  49. 9 1
      netbox/dcim/models/sites.py
  50. 2 0
      netbox/dcim/svg/__init__.py
  51. 2 257
      netbox/dcim/svg/cables.py
  52. 300 0
      netbox/dcim/svg/racks.py
  53. 4 4
      netbox/dcim/tables/devices.py
  54. 4 3
      netbox/dcim/tables/sites.py
  55. 1 1
      netbox/dcim/tables/template_code.py
  56. 14 9
      netbox/dcim/tests/test_api.py
  57. 118 11
      netbox/dcim/tests/test_filtersets.py
  58. 103 111
      netbox/dcim/tests/test_models.py
  59. 18 11
      netbox/dcim/tests/test_views.py
  60. 2 2
      netbox/dcim/views.py
  61. 15 8
      netbox/extras/api/serializers.py
  62. 1 1
      netbox/extras/api/views.py
  63. 13 0
      netbox/extras/choices.py
  64. 17 2
      netbox/extras/filtersets.py
  65. 11 1
      netbox/extras/forms/bulk_edit.py
  66. 9 2
      netbox/extras/forms/bulk_import.py
  67. 11 0
      netbox/extras/forms/customfields.py
  68. 17 3
      netbox/extras/forms/filtersets.py
  69. 21 7
      netbox/extras/forms/models.py
  70. 3 2
      netbox/extras/management/commands/runscript.py
  71. 22 0
      netbox/extras/migrations/0074_customfield_group_name.py
  72. 18 0
      netbox/extras/migrations/0075_customfield_ui_visibility.py
  73. 19 0
      netbox/extras/migrations/0076_configcontext_locations.py
  74. 7 5
      netbox/extras/models/configcontexts.py
  75. 13 1
      netbox/extras/models/customfields.py
  76. 4 1
      netbox/extras/querysets.py
  77. 13 5
      netbox/extras/scripts.py
  78. 7 5
      netbox/extras/tables/tables.py
  79. 23 7
      netbox/extras/tests/test_filtersets.py
  80. 29 19
      netbox/extras/tests/test_models.py
  81. 6 4
      netbox/extras/tests/test_views.py
  82. 1 1
      netbox/extras/views.py
  83. 1 2
      netbox/ipam/api/serializers.py
  84. 13 7
      netbox/ipam/filtersets.py
  85. 17 0
      netbox/ipam/migrations/0058_ipaddress_nat_inside_nonunique.py
  86. 1 1
      netbox/ipam/models/ip.py
  87. 2 1
      netbox/ipam/tables/ip.py
  88. 4 2
      netbox/ipam/tables/services.py
  89. 2 4
      netbox/ipam/tests/test_filtersets.py
  90. 22 5
      netbox/ipam/views.py
  91. 2 1
      netbox/netbox/api/__init__.py
  92. 23 1
      netbox/netbox/api/authentication.py
  93. 19 2
      netbox/netbox/api/fields.py
  94. 17 1
      netbox/netbox/api/pagination.py
  95. 3 0
      netbox/netbox/configuration_example.py
  96. 5 0
      netbox/netbox/configuration_testing.py
  97. 65 67
      netbox/netbox/constants.py
  98. 18 2
      netbox/netbox/models/features.py
  99. 6 1
      netbox/netbox/settings.py
  100. 63 16
      netbox/netbox/tables/columns.py

+ 1 - 1
.github/ISSUE_TEMPLATE/bug_report.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
       label: NetBox version
       description: What version of NetBox are you currently running?
-      placeholder: v3.2.3
+      placeholder: v3.2.5
     validations:
       required: true
   - type: dropdown

+ 1 - 1
.github/ISSUE_TEMPLATE/feature_request.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
       label: NetBox version
       description: What version of NetBox are you currently running?
-      placeholder: v3.2.3
+      placeholder: v3.2.5
     validations:
       required: true
   - type: dropdown

+ 5 - 2
.github/workflows/stale.yml

@@ -8,7 +8,7 @@ jobs:
   stale:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/stale@v4
+      - uses: actions/stale@v5
         with:
           close-issue-message: >
             This issue has been automatically closed due to lack of activity. In an
@@ -27,7 +27,10 @@ jobs:
             This issue has been automatically marked as stale because it has not had
             recent activity. It will be closed if no further activity occurs. NetBox
             is governed by a small group of core maintainers which means not all opened
-            issues may receive direct feedback. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).
+            issues may receive direct feedback. **Do not** attempt to circumvent this
+            process by "bumping" the issue; doing so will result in its immediate closure
+            and you may be barred from participating in any future discussions. Please see
+            our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).
           stale-pr-label: 'pending closure'
           stale-pr-message: >
             This PR has been automatically marked as stale because it has not had

+ 3 - 3
CONTRIBUTING.md

@@ -160,9 +160,9 @@ to aid in issue management.
 
 It is natural that some new issues get more attention than others. The stale
 bot helps bring renewed attention to potentially valuable issues that may have
-been overlooked. **Do not** comment on an issue that has been marked stale in
-an effort to circumvent the bot: Doing so will not remove the stale label.
-(Stale labels can be removed only by maintainers.)
+been overlooked. **Do not** comment on a stale issue merely to "bump" it in an
+effort to circumvent the bot: This will result in the immediate closure of the
+issue, and you may be barred from participating in future discussions.
 
 ## Maintainer Guidance
 

+ 11 - 2
base_requirements.txt

@@ -30,10 +30,14 @@ django-pglocks
 # https://github.com/korfuri/django-prometheus
 django-prometheus
 
-# Django chaching backend using Redis
+# Django caching backend using Redis
 # https://github.com/jazzband/django-redis
 django-redis
 
+# Django extensions for Rich (terminal text rendering)
+# https://github.com/adamchainz/django-rich
+django-rich
+
 # Django integration for RQ (Reqis queuing)
 # https://github.com/rq/django-rq
 django-rq
@@ -44,7 +48,8 @@ django-tables2
 
 # User-defined tags for objects
 # https://github.com/alex/django-taggit
-django-taggit
+# Will evaluate v3.0 during NetBox v3.3 beta
+django-taggit>=2.1.0,<3.0
 
 # A Django field for representing time zones
 # https://github.com/mfogel/django-timezone-field/
@@ -125,3 +130,7 @@ tablib
 # Timezone data (required by django-timezone-field on Python 3.9+)
 # https://github.com/python/tzdata
 tzdata
+
+# HTML sanitizer
+# https://github.com/mozilla/bleach
+bleach

+ 25 - 0
docs/configuration/optional-settings.md

@@ -66,6 +66,14 @@ CORS_ORIGIN_WHITELIST = [
 
 ---
 
+## CSRF_COOKIE_NAME
+
+Default: `csrftoken`
+
+The name of the cookie to use for the cross-site request forgery (CSRF) authentication token. See the [Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#csrf-cookie-name) for more detail.
+
+---
+
 ## CSRF_TRUSTED_ORIGINS
 
 Default: `[]`
@@ -247,6 +255,23 @@ HTTP_PROXIES = {
 
 ---
 
+## JINJA2_FILTERS
+
+Default: `{}`
+
+A dictionary of custom jinja2 filters with the key being the filter name and the value being a callable. For more information see the [Jinja2 documentation](https://jinja.palletsprojects.com/en/3.1.x/api/#custom-filters). For example:
+
+```python
+def uppercase(x):
+    return str(x).upper()
+
+JINJA2_FILTERS = {
+    'uppercase': uppercase,
+}
+```
+
+---
+
 ## INTERNAL_IPS
 
 Default: `('127.0.0.1', '::1')`

+ 1 - 1
docs/models/circuits/circuit.md

@@ -13,7 +13,7 @@ Each circuit is also assigned one of the following operational statuses:
 * Deprovisioning
 * Decommissioned
 
-Circuits also have optional fields for annotating their installation date and commit rate, and may be assigned to NetBox tenants.
+Circuits also have optional fields for annotating their installation and termination dates and commit rate, and may be assigned to NetBox tenants.
 
 !!! note
     NetBox currently models only physical circuits: those which have exactly two endpoints. It is common to layer virtualized constructs (_virtual circuits_) such as MPLS or EVPN tunnels on top of these, however NetBox does not yet support virtual circuit modeling.

+ 4 - 0
docs/models/dcim/interface.md

@@ -11,6 +11,10 @@ Interfaces may be physical or virtual in nature, but only physical interfaces ma
 
 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.
 
+### Power over Ethernet (PoE)
+
+Physical interfaces can be assigned a PoE mode to indicate PoE capability: power supplying equipment (PSE) or powered device (PD). Additionally, a PoE mode may be specified. This can be one of the listed IEEE 802.3 standards, or a passive setting (24 or 48 volts across two or four pairs).
+
 ### Wireless Interfaces
 
 Wireless interfaces may additionally track the following attributes:

+ 1 - 2
docs/models/dcim/location.md

@@ -2,5 +2,4 @@
 
 Racks and devices can be grouped by location within a site. A location may represent a floor, room, cage, or similar organizational unit. Locations can be nested to form a hierarchy. For example, you may have floors within a site, and rooms within a floor.
 
-Each location must have a name that is unique within its parent site and location, if any.
-
+Each location must have a name that is unique within its parent site and location, if any, and must be assigned an operational status. (The set of available statuses is configurable.)

+ 2 - 0
docs/models/extras/configcontext.md

@@ -5,9 +5,11 @@ Sometimes it is desirable to associate additional data with a group of devices o
 * Region
 * Site group
 * Site
+* Location (devices only)
 * Device type (devices only)
 * Role
 * Platform
+* Cluster type (VMs only)
 * Cluster group (VMs only)
 * Cluster (VMs only)
 * Tenant group

+ 1 - 1
docs/models/users/token.md

@@ -9,4 +9,4 @@ Each token contains a 160-bit key represented as 40 hexadecimal characters. When
 
 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.
+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. Tokens can also be restricted by IP range: If defined, authentication for API clients connecting from an IP address outside these ranges will fail.

+ 1 - 1
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 (technological classification), and may optionally be assigned to a cluster group, site, and/or tenant. Each cluster must have a unique name within its assigned group and/or site, if any.
+A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type (technological classification) and operational status, and may optionally be assigned to a cluster group, site, and/or tenant. Each cluster must have a unique name within its assigned group and/or site, if any.
 
 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.

+ 1 - 1
docs/models/virtualization/virtualmachine.md

@@ -1,6 +1,6 @@
 # Virtual Machines
 
-A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be assigned to exactly one cluster.
+A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be assigned to a site and/or cluster, and may optionally be assigned to a particular host device within a cluster.
 
 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:
 

+ 2 - 1
docs/plugins/development/tables.md

@@ -85,4 +85,5 @@ The table column classes listed below are supported for use in plugins. These cl
 
 ::: netbox.tables.TemplateColumn
     selection:
-      members: false
+      members:
+        - __init__

+ 353 - 0
docs/reference/markdown.md

@@ -0,0 +1,353 @@
+---
+hide:
+  - toc
+---
+
+# Markdown
+
+NetBox supports markdown rendering for certain text fields.
+
+## Syntax
+
+##### Table of Contents  
+[Headers](#headers)  
+[Emphasis](#emphasis)  
+[Lists](#lists)  
+[Links](#links)  
+[Images](#images)  
+[Code Blocks](#code)  
+[Tables](#tables)  
+[Blockquotes](#blockquotes)  
+[Inline HTML](#html)  
+[Horizontal Rule](#hr)  
+[Line Breaks](#lines)  
+
+<a name="headers"></a>
+
+## Headers
+
+```no-highlight
+# H1
+## H2
+### H3
+#### H4
+##### H5
+###### H6
+
+Alternatively, for H1 and H2, an underline-ish style:
+
+Alt-H1
+======
+
+Alt-H2
+------
+```
+
+# H1
+## H2
+### H3
+#### H4
+##### H5
+###### H6
+
+<a name="emphasis"></a>
+
+## Emphasis
+
+```no-highlight
+Emphasis, aka italics, with *asterisks* or _underscores_.
+
+Strong emphasis, aka bold, with **asterisks** or __underscores__.
+
+Combined emphasis with **asterisks and _underscores_**.
+
+Strikethrough uses two tildes. ~~Scratch this.~~
+```
+
+Emphasis, aka italics, with *asterisks* or _underscores_.
+
+Strong emphasis, aka bold, with **asterisks** or __underscores__.
+
+Combined emphasis with **asterisks and _underscores_**.
+
+Strikethrough uses two tildes. ~~Scratch this.~~
+
+
+<a name="lists"></a>
+
+## Lists
+
+(In this example, leading and trailing spaces are shown with with dots: ⋅)
+
+```no-highlight
+1. First ordered list item
+2. Another item
+⋅⋅* Unordered sub-list. 
+1. Actual numbers don't matter, just that it's a number
+⋅⋅1. Ordered sub-list
+4. And another item.
+
+⋅⋅⋅You can have properly indented paragraphs within list items. Notice the blank line above, and the leading spaces (at least one, but we'll use three here to also align the raw Markdown).
+
+⋅⋅⋅To have a line break without a paragraph, you will need to use two trailing spaces.⋅⋅
+⋅⋅⋅Note that this line is separate, but within the same paragraph.⋅⋅
+⋅⋅⋅(This is contrary to the typical GFM line break behaviour, where trailing spaces are not required.)
+
+* Unordered list can use asterisks
+- Or minuses
++ Or pluses
+```
+
+1. First ordered list item
+2. Another item
+  * Unordered sub-list. 
+1. Actual numbers don't matter, just that it's a number
+  1. Ordered sub-list
+4. And another item.
+
+   You can have properly indented paragraphs within list items. Notice the blank line above, and the leading spaces (at least one, but we'll use three here to also align the raw Markdown).
+
+   To have a line break without a paragraph, you will need to use two trailing spaces.  
+   Note that this line is separate, but within the same paragraph.  
+   (This is contrary to the typical GFM line break behaviour, where trailing spaces are not required.)
+
+* Unordered list can use asterisks
+- Or minuses
++ Or pluses
+
+<a name="links"></a>
+
+## Links
+
+There are two ways to create links.
+
+```no-highlight
+[I'm an inline-style link](https://www.google.com)
+
+[I'm an inline-style link with title](https://www.google.com "Google's Homepage")
+
+[I'm a reference-style link][Arbitrary case-insensitive reference text]
+
+[You can use numbers for reference-style link definitions][1]
+
+Or leave it empty and use the [link text itself].
+
+URLs and URLs in angle brackets will automatically get turned into links. 
+http://www.example.com or <http://www.example.com> and sometimes 
+example.com (but not on Github, for example).
+
+Some text to show that the reference links can follow later.
+
+[arbitrary case-insensitive reference text]: https://www.mozilla.org
+[1]: http://slashdot.org
+[link text itself]: http://www.reddit.com
+```
+
+[I'm an inline-style link](https://www.google.com)
+
+[I'm an inline-style link with title](https://www.google.com "Google's Homepage")
+
+[I'm a reference-style link][Arbitrary case-insensitive reference text]
+
+[You can use numbers for reference-style link definitions][1]
+
+Or leave it empty and use the [link text itself].
+
+URLs and URLs in angle brackets will automatically get turned into links. 
+http://www.example.com or <http://www.example.com> and sometimes 
+example.com (but not on Github, for example).
+
+Some text to show that the reference links can follow later.
+
+[arbitrary case-insensitive reference text]: https://www.mozilla.org
+[1]: http://slashdot.org
+[link text itself]: http://www.reddit.com
+
+<a name="images"></a>
+
+## Images
+
+```
+Here's the Netbox logo (hover to see the title text):
+
+Inline-style: 
+![alt text](/static/netbox_logo.png "Logo Title Text 1")
+
+Reference-style: 
+![alt text][logo]
+
+[logo]: /static/netbox_logo.png "Logo Title Text 2"
+```
+
+Here's the Netbox logo (hover to see the title text):
+
+Inline-style: 
+![alt text](/static/netbox_logo.png "Logo Title Text 1")
+
+Reference-style: 
+![alt text][logo]
+
+[logo]: /static/netbox_logo.png "Logo Title Text 2"
+
+<a name="code"></a>
+
+## Code blocks
+
+```
+Inline `code` has `back-ticks around` it.
+```
+
+Inline `code` has `back-ticks around` it.
+
+Blocks of code are fenced by lines with three back-ticks <code>```</code>
+
+````
+```
+var s = "Code block";
+alert(s);
+```
+````
+
+```
+var s = "Code block";
+alert(s);
+```
+
+<a name="tables"></a>
+
+## Tables
+
+```no-highlight
+Colons can be used to align columns.
+
+| Tables        | Are           | Cool  |
+| ------------- |:-------------:| -----:|
+| col 3 is      | right-aligned | $1600 |
+| col 2 is      | centered      |   $12 |
+| zebra stripes | are neat      |    $1 |
+
+There must be at least 3 dashes separating each header cell.
+The outer pipes (|) are optional, and you don't need to make the 
+raw Markdown line up prettily. You can also use inline Markdown.
+
+Markdown | Less | Pretty
+--- | --- | ---
+*Still* | `renders` | **nicely**
+1 | 2 | 3
+```
+
+Colons can be used to align columns.
+
+| Tables        | Are           | Cool |
+| ------------- |:-------------:| -----:|
+| col 3 is      | right-aligned | $1600 |
+| col 2 is      | centered      |   $12 |
+| zebra stripes | are neat      |    $1 |
+
+There must be at least 3 dashes separating each header cell. The outer pipes (|) are optional, and you don't need to make the raw Markdown line up prettily. You can also use inline Markdown.
+
+Markdown | Less | Pretty
+--- | --- | ---
+*Still* | `renders` | **nicely**
+1 | 2 | 3
+
+<a name="blockquotes"></a>
+
+## Blockquotes
+
+```no-highlight
+> Blockquotes are very handy in email to emulate reply text.
+> This line is part of the same quote.
+
+Quote break.
+
+> This is a very long line that will still be quoted properly when it wraps. Oh boy let's keep writing to make sure this is long enough to actually wrap for everyone. Oh, you can *put* **Markdown** into a blockquote. 
+```
+
+> Blockquotes are very handy in email to emulate reply text.
+> This line is part of the same quote.
+
+Quote break.
+
+> This is a very long line that will still be quoted properly when it wraps. Oh boy let's keep writing to make sure this is long enough to actually wrap for everyone. Oh, you can *put* **Markdown** into a blockquote. 
+
+<a name="html"></a>
+
+## Inline HTML
+
+You can also use raw HTML in your Markdown, and it'll mostly work pretty well. 
+
+```no-highlight
+<dl>
+  <dt>Definition list</dt>
+  <dd>Is something people use sometimes.</dd>
+
+  <dt>Markdown in HTML</dt>
+  <dd>Does *not* work **very** well. Use HTML <em>tags</em>.</dd>
+</dl>
+```
+
+<dl>
+  <dt>Definition list</dt>
+  <dd>Is something people use sometimes.</dd>
+
+  <dt>Markdown in HTML</dt>
+  <dd>Does *not* work **very** well. Use HTML <em>tags</em>.</dd>
+</dl>
+
+<a name="hr"></a>
+
+## Horizontal Rule
+
+```
+Three or more...
+
+---
+
+Hyphens
+
+***
+
+Asterisks
+
+___
+
+Underscores
+```
+
+Three or more...
+
+---
+
+Hyphens
+
+***
+
+Asterisks
+
+___
+
+Underscores
+
+<a name="lines"></a>
+
+## Line Breaks
+
+
+```
+Here's a line for us to start with.
+
+This line is separated from the one above by two newlines, so it will be a *separate paragraph*.
+
+This line is also a separate paragraph, but...
+This line is only separated by a single newline, so it's a separate line in the *same paragraph*.
+```
+
+Here's a line for us to start with.
+
+This line is separated from the one above by two newlines, so it will be a *separate paragraph*.
+
+This line is also begins a separate paragraph, but...  
+This line is only separated by a single newline, so it's a separate line in the *same paragraph*.
+
+Based on [Markdown-Cheatsheet](https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet) by [adam-p](https://github.com/adam-p) licensed under [CC-BY](https://creativecommons.org/licenses/by/3.0/)

+ 64 - 0
docs/release-notes/version-3.2.md

@@ -1,5 +1,69 @@
 # NetBox v3.2
 
+## v3.2.6 (FUTURE)
+
+---
+
+## v3.2.5 (2022-06-20)
+
+### Enhancements
+
+* [#8704](https://github.com/netbox-community/netbox/issues/8704) - Shift-click to select multiple objects in a list
+* [#8882](https://github.com/netbox-community/netbox/issues/8882) - Support filtering IP addresses by multiple parent prefixes
+* [#8893](https://github.com/netbox-community/netbox/issues/8893) - Include count of IP ranges under tenant view
+* [#9417](https://github.com/netbox-community/netbox/issues/9417) - Initialize manufacturer selection when inserting a new module
+* [#9501](https://github.com/netbox-community/netbox/issues/9501) - Add support for custom Jinja2 filters
+* [#9517](https://github.com/netbox-community/netbox/issues/9517) - Linkify related power port on power outlet view
+* [#9525](https://github.com/netbox-community/netbox/issues/9525) - Provide one-click edit link for objects in tables
+* [#9533](https://github.com/netbox-community/netbox/issues/9533) - Move Markdown reference to local documentation
+* [#9534](https://github.com/netbox-community/netbox/issues/9534) - Add VLAN group selector to interface bulk edit forms
+* [#9556](https://github.com/netbox-community/netbox/issues/9556) - Leave dropdown open upon selection for multi-select fields
+
+### Bug Fixes
+
+* [#8944](https://github.com/netbox-community/netbox/issues/8944) - Fix rendering of Markdown links with colons
+* [#9108](https://github.com/netbox-community/netbox/issues/9108) - Fix rendering of bracketed Markdown links
+* [#9374](https://github.com/netbox-community/netbox/issues/9374) - Improve performance when retrieving devices/VMs with config context data
+* [#9466](https://github.com/netbox-community/netbox/issues/9466) - Avoid sending webhooks after script/report failure
+* [#9480](https://github.com/netbox-community/netbox/issues/9480) - Fix sorting services & service templates by port numbers
+* [#9484](https://github.com/netbox-community/netbox/issues/9484) - Include services listening on "all IPs" under IP address view
+* [#9486](https://github.com/netbox-community/netbox/issues/9486) - Fix redirect URL when adding device components from the module view
+* [#9495](https://github.com/netbox-community/netbox/issues/9495) - Correct link to contacts in contact groups table column
+* [#9503](https://github.com/netbox-community/netbox/issues/9503) - Hyperlinks in rack elevation SVGs must always use absolute URLs
+* [#9512](https://github.com/netbox-community/netbox/issues/9512) - Fix duplicate site results when searching by ASN
+* [#9524](https://github.com/netbox-community/netbox/issues/9524) - Correct order of VLAN fields under VM interface creation form
+* [#9537](https://github.com/netbox-community/netbox/issues/9537) - Ensure consistent use of placeholder tag throughout UI
+* [#9549](https://github.com/netbox-community/netbox/issues/9549) - Fix device counts for rack list under rack role view
+
+---
+
+## v3.2.4 (2022-05-31)
+
+### Enhancements
+
+* [#8374](https://github.com/netbox-community/netbox/issues/8374) - Display device type and asset tag if name is blank but asset tag is populated
+* [#8922](https://github.com/netbox-community/netbox/issues/8922) - Add service list to IP address view
+* [#9098](https://github.com/netbox-community/netbox/issues/9098) - Add "other" types for power ports/outlets, pass-through ports
+* [#9239](https://github.com/netbox-community/netbox/issues/9239) - Enable filtering by contact group for all models which support contact assignment
+* [#9277](https://github.com/netbox-community/netbox/issues/9277) - Introduce `CSRF_COOKIE_NAME` configuration parameter
+* [#9347](https://github.com/netbox-community/netbox/issues/9347) - Include services in global search
+* [#9379](https://github.com/netbox-community/netbox/issues/9379) - Redirect to virtual chassis view after adding a member device
+* [#9451](https://github.com/netbox-community/netbox/issues/9451) - Add `export_raw` argument for TemplateColumn
+
+### Bug Fixes
+
+* [#9094](https://github.com/netbox-community/netbox/issues/9094) - Fix partial address search within Prefix and Aggregate filters
+* [#9291](https://github.com/netbox-community/netbox/issues/9291) - Improve data validation for MultiObjectVar script fields
+* [#9358](https://github.com/netbox-community/netbox/issues/9358) - Annotate circuit count for providers list under ASN view
+* [#9387](https://github.com/netbox-community/netbox/issues/9387) - Ensure ActionsColumn `extra_buttons` are always displayed
+* [#9402](https://github.com/netbox-community/netbox/issues/9402) - Fix custom field population when creating a virtual chassis
+* [#9407](https://github.com/netbox-community/netbox/issues/9407) - Clean up display of prefixes values when exporting prefixes list
+* [#9420](https://github.com/netbox-community/netbox/issues/9420) - Fix custom script class inheritance
+* [#9425](https://github.com/netbox-community/netbox/issues/9425) - Fix bulk import for object and multi-object custom fields
+* [#9430](https://github.com/netbox-community/netbox/issues/9430) - Fix passing of initial form data for DynamicModelChoiceFields
+
+---
+
 ## v3.2.3 (2022-05-12)
 
 ### Enhancements

+ 63 - 0
docs/release-notes/version-3.3.md

@@ -0,0 +1,63 @@
+# NetBox v3.3
+
+## v3.3.0 (FUTURE)
+
+### Breaking Changes
+
+* Device position and rack unit values are now reported as decimals (e.g. `1.0` or `1.5`) to support modeling half-height rack units.
+* The `nat_outside` relation on the IP address model now returns a list of zero or more related IP addresses, rather than a single instance (or None).
+
+### New Features
+
+#### Half-Height Rack Units ([#51](https://github.com/netbox-community/netbox/issues/51))
+
+#### PoE Interface Attributes ([#1099](https://github.com/netbox-community/netbox/issues/1099))
+
+#### Restrict API Tokens by Client IP ([#8233](https://github.com/netbox-community/netbox/issues/8233))
+
+### Enhancements
+
+* [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses
+* [#4350](https://github.com/netbox-community/netbox/issues/4350) - Illustrate reservations vertically alongside rack elevations
+* [#5303](https://github.com/netbox-community/netbox/issues/5303) - A virtual machine may be assigned to a site and/or cluster
+* [#7120](https://github.com/netbox-community/netbox/issues/7120) - Add `termination_date` field to Circuit
+* [#7744](https://github.com/netbox-community/netbox/issues/7744) - Add `status` field to Location
+* [#8222](https://github.com/netbox-community/netbox/issues/8222) - Enable the assignment of a VM to a specific host device within a cluster
+* [#8471](https://github.com/netbox-community/netbox/issues/8471) - Add `status` field to Cluster
+* [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping
+* [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results
+* [#9166](https://github.com/netbox-community/netbox/issues/9166) - Add UI visibility toggle for custom fields
+* [#9582](https://github.com/netbox-community/netbox/issues/9582) - Enable assigning config contexts based on device location
+
+### Other Changes
+
+* [#9261](https://github.com/netbox-community/netbox/issues/9261) - `NetBoxTable` no longer automatically clears pre-existing calls to `prefetch_related()` on its queryset
+* [#9434](https://github.com/netbox-community/netbox/issues/9434) - Enabled `django-rich` test runner for more user-friendly output
+
+### REST API Changes
+
+* circuits.Circuit
+    * Added optional `termination_date` field
+* dcim.Device
+    * The `position` field has been changed from an integer to a decimal
+* dcim.DeviceType
+    * The `u_height` field has been changed from an integer to a decimal
+* dcim.Interface
+    * Added the optional `poe_mode` and `poe_type` fields
+* dcim.Location
+    * Added required `status` field (default value: `active`)
+* dcim.Rack
+    * The `elevation` endpoint now includes half-height rack units, and utilizes decimal values for the ID and name of each unit
+* extras.ConfigContext
+    * Added the `locations` many-to-many field to track the assignment of ConfigContexts to Locations
+* extras.CustomField
+    * Added `group_name` and `ui_visibility` fields
+* ipam.IPAddress
+    * The `nat_inside` field no longer requires a unique value
+    * The `nat_outside` field has changed from a single IP address instance to a list of multiple IP addresses
+* virtualization.Cluster
+    * Added required `status` field (default value: `active`)
+* virtualization.VirtualMachine
+    * Added `device` field
+    * The `site` field is now directly writable (rather than being inferred from the assigned cluster)
+    * The `cluster` field is now optional. A virtual machine must have a site and/or cluster assigned.

+ 20 - 0
docs/rest-api/filtering.md

@@ -106,3 +106,23 @@ expression: `n`. Here is an example of a lookup expression on a foreign key, it
 ```no-highlight
 GET /api/ipam/vlans/?group_id__n=3203
 ```
+
+## Ordering Objects
+
+To order results by a particular field, include the `ordering` query parameter. For example, order the list of sites according to their facility values:
+
+```no-highlight
+GET /api/dcim/sites/?ordering=facility
+```
+
+To invert the ordering, prepend a hyphen to the field name:
+
+```no-highlight
+GET /api/dcim/sites/?ordering=-facility
+```
+
+Multiple fields can be specified by separating the field names with a comma. For example:
+
+```no-highlight
+GET /api/dcim/sites/?ordering=facility,-name
+```

+ 1 - 0
mkdocs.yml

@@ -136,6 +136,7 @@ nav:
         - Overview: 'graphql-api/overview.md'
     - Reference:
         - Conditions: 'reference/conditions.md'
+        - Markdown: 'reference/markdown.md'
     - Development:
         - Introduction: 'development/index.md'
         - Getting Started: 'development/getting-started.md'

+ 3 - 3
netbox/circuits/api/serializers.py

@@ -92,9 +92,9 @@ class CircuitSerializer(NetBoxModelSerializer):
     class Meta:
         model = Circuit
         fields = [
-            'id', 'url', 'display', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate',
-            'description', 'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields', 'created',
-            'last_updated',
+            'id', 'url', 'display', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date',
+            'commit_rate', 'description', 'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields',
+            'created', 'last_updated',
         ]
 
 

+ 1 - 1
netbox/circuits/filtersets.py

@@ -183,7 +183,7 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
 
     class Meta:
         model = Circuit
-        fields = ['id', 'cid', 'description', 'install_date', 'commit_rate']
+        fields = ['id', 'cid', 'description', 'install_date', 'termination_date', 'commit_rate']
 
     def search(self, queryset, name, value):
         if not value.strip():

+ 12 - 2
netbox/circuits/forms/bulk_edit.py

@@ -7,7 +7,7 @@ from ipam.models import ASN
 from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import Tenant
 from utilities.forms import (
-    add_blank_choice, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea,
+    add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea,
     StaticSelect,
 )
 
@@ -122,6 +122,14 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
         queryset=Tenant.objects.all(),
         required=False
     )
+    install_date = forms.DateField(
+        required=False,
+        widget=DatePicker()
+    )
+    termination_date = forms.DateField(
+        required=False,
+        widget=DatePicker()
+    )
     commit_rate = forms.IntegerField(
         required=False,
         label='Commit rate (Kbps)'
@@ -137,7 +145,9 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
 
     model = Circuit
     fieldsets = (
-        (None, ('type', 'provider', 'status', 'tenant', 'commit_rate', 'description')),
+        ('Circuit', ('provider', 'type', 'status', 'description')),
+        ('Service Parameters', ('install_date', 'termination_date', 'commit_rate')),
+        ('Tenancy', ('tenant',)),
     )
     nullable_fields = (
         'tenant', 'commit_rate', 'description', 'comments',

+ 2 - 1
netbox/circuits/forms/bulk_import.py

@@ -72,5 +72,6 @@ class CircuitCSVForm(NetBoxModelCSVForm):
     class Meta:
         model = Circuit
         fields = [
-            'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
+            'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate',
+            'description', 'comments',
         ]

+ 12 - 4
netbox/circuits/forms/filtersets.py

@@ -7,7 +7,7 @@ from dcim.models import Region, Site, SiteGroup
 from ipam.models import ASN
 from netbox.forms import NetBoxModelFilterSetForm
 from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
-from utilities.forms import DynamicModelMultipleChoiceField, MultipleChoiceField, TagFilterField
+from utilities.forms import DatePicker, DynamicModelMultipleChoiceField, MultipleChoiceField, TagFilterField
 
 __all__ = (
     'CircuitFilterForm',
@@ -23,7 +23,7 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
         (None, ('q', 'tag')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
         ('ASN', ('asn',)),
-        ('Contacts', ('contact', 'contact_role')),
+        ('Contacts', ('contact', 'contact_role', 'contact_group')),
     )
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
@@ -84,10 +84,10 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
     fieldsets = (
         (None, ('q', 'tag')),
         ('Provider', ('provider_id', 'provider_network_id')),
-        ('Attributes', ('type_id', 'status', 'commit_rate')),
+        ('Attributes', ('type_id', 'status', 'install_date', 'termination_date', 'commit_rate')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
-        ('Contacts', ('contact', 'contact_role')),
+        ('Contacts', ('contact', 'contact_role', 'contact_group')),
     )
     type_id = DynamicModelMultipleChoiceField(
         queryset=CircuitType.objects.all(),
@@ -130,6 +130,14 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
         },
         label=_('Site')
     )
+    install_date = forms.DateField(
+        required=False,
+        widget=DatePicker
+    )
+    termination_date = forms.DateField(
+        required=False,
+        widget=DatePicker
+    )
     commit_rate = forms.IntegerField(
         required=False,
         min_value=0,

+ 5 - 3
netbox/circuits/forms/models.py

@@ -93,15 +93,16 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
     comments = CommentField()
 
     fieldsets = (
-        ('Circuit', ('provider', 'cid', 'type', 'status', 'install_date', 'commit_rate', 'description', 'tags')),
+        ('Circuit', ('provider', 'cid', 'type', 'status', 'description', 'tags')),
+        ('Service Parameters', ('install_date', 'termination_date', 'commit_rate')),
         ('Tenancy', ('tenant_group', 'tenant')),
     )
 
     class Meta:
         model = Circuit
         fields = [
-            'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant',
-            'comments', 'tags',
+            'cid', 'type', 'provider', 'status', 'install_date', 'termination_date', 'commit_rate', 'description',
+            'tenant_group', 'tenant', 'comments', 'tags',
         ]
         help_texts = {
             'cid': "Unique circuit ID",
@@ -110,6 +111,7 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
         widgets = {
             'status': StaticSelect(),
             'install_date': DatePicker(),
+            'termination_date': DatePicker(),
             'commit_rate': SelectSpeedWidget(),
         }
 

+ 18 - 0
netbox/circuits/migrations/0036_circuit_termination_date.py

@@ -0,0 +1,18 @@
+# Generated by Django 4.0.5 on 2022-06-22 18:51
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('circuits', '0035_provider_asns'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='circuit',
+            name='termination_date',
+            field=models.DateField(blank=True, null=True),
+        ),
+    ]

+ 7 - 2
netbox/circuits/models/circuits.py

@@ -78,7 +78,12 @@ class Circuit(NetBoxModel):
     install_date = models.DateField(
         blank=True,
         null=True,
-        verbose_name='Date installed'
+        verbose_name='Installed'
+    )
+    termination_date = models.DateField(
+        blank=True,
+        null=True,
+        verbose_name='Terminates'
     )
     commit_rate = models.PositiveIntegerField(
         blank=True,
@@ -119,7 +124,7 @@ class Circuit(NetBoxModel):
     )
 
     clone_fields = [
-        'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
+        'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate', 'description',
     ]
 
     class Meta:

+ 1 - 1
netbox/circuits/tables/circuits.py

@@ -70,7 +70,7 @@ class CircuitTable(NetBoxTable):
         model = Circuit
         fields = (
             'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date',
-            'commit_rate', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated',
+            'termination_date', 'commit_rate', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated',
         )
         default_columns = (
             'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description',

+ 10 - 6
netbox/circuits/tests/test_filtersets.py

@@ -208,12 +208,12 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
         ProviderNetwork.objects.bulk_create(provider_networks)
 
         circuits = (
-            Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar1'),
-            Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar2'),
-            Circuit(provider=providers[0], tenant=tenants[1], type=circuit_types[0], cid='Test Circuit 3', install_date='2020-01-03', commit_rate=3000, status=CircuitStatusChoices.STATUS_PLANNED),
-            Circuit(provider=providers[1], tenant=tenants[1], type=circuit_types[1], cid='Test Circuit 4', install_date='2020-01-04', commit_rate=4000, status=CircuitStatusChoices.STATUS_PLANNED),
-            Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 5', install_date='2020-01-05', commit_rate=5000, status=CircuitStatusChoices.STATUS_OFFLINE),
-            Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 6', install_date='2020-01-06', commit_rate=6000, status=CircuitStatusChoices.STATUS_OFFLINE),
+            Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', termination_date='2021-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar1'),
+            Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', termination_date='2021-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar2'),
+            Circuit(provider=providers[0], tenant=tenants[1], type=circuit_types[0], cid='Test Circuit 3', install_date='2020-01-03', termination_date='2021-01-03', commit_rate=3000, status=CircuitStatusChoices.STATUS_PLANNED),
+            Circuit(provider=providers[1], tenant=tenants[1], type=circuit_types[1], cid='Test Circuit 4', install_date='2020-01-04', termination_date='2021-01-04', commit_rate=4000, status=CircuitStatusChoices.STATUS_PLANNED),
+            Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 5', install_date='2020-01-05', termination_date='2021-01-05', commit_rate=5000, status=CircuitStatusChoices.STATUS_OFFLINE),
+            Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 6', install_date='2020-01-06', termination_date='2021-01-06', commit_rate=6000, status=CircuitStatusChoices.STATUS_OFFLINE),
         )
         Circuit.objects.bulk_create(circuits)
 
@@ -235,6 +235,10 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'install_date': ['2020-01-01', '2020-01-02']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_termination_date(self):
+        params = {'termination_date': ['2021-01-01', '2021-01-02']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_commit_rate(self):
         params = {'commit_rate': ['1000', '2000']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

+ 1 - 0
netbox/circuits/tests/test_views.py

@@ -130,6 +130,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'status': CircuitStatusChoices.STATUS_DECOMMISSIONED,
             'tenant': None,
             'install_date': datetime.date(2020, 1, 1),
+            'termination_date': datetime.date(2021, 1, 1),
             'commit_rate': 1000,
             'description': 'A new circuit',
             'comments': 'Some comments',

+ 35 - 9
netbox/dcim/api/serializers.py

@@ -1,3 +1,5 @@
+import decimal
+
 from django.contrib.contenttypes.models import ContentType
 from drf_yasg.utils import swagger_serializer_method
 from rest_framework import serializers
@@ -149,6 +151,7 @@ class LocationSerializer(NestedGroupModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail')
     site = NestedSiteSerializer()
     parent = NestedLocationSerializer(required=False, allow_null=True)
+    status = ChoiceField(choices=LocationStatusChoices, required=False)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     rack_count = serializers.IntegerField(read_only=True)
     device_count = serializers.IntegerField(read_only=True)
@@ -156,8 +159,8 @@ class LocationSerializer(NestedGroupModelSerializer):
     class Meta:
         model = Location
         fields = [
-            'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'tenant', 'description', 'tags', 'custom_fields',
-            'created', 'last_updated', 'rack_count', 'device_count', '_depth',
+            'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'description', 'tags',
+            'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth',
         ]
 
 
@@ -201,7 +204,11 @@ class RackUnitSerializer(serializers.Serializer):
     """
     A rack unit is an abstraction formed by the set (rack, position, face); it does not exist as a row in the database.
     """
-    id = serializers.IntegerField(read_only=True)
+    id = serializers.DecimalField(
+        max_digits=4,
+        decimal_places=1,
+        read_only=True
+    )
     name = serializers.CharField(read_only=True)
     face = ChoiceField(choices=DeviceFaceChoices, read_only=True)
     device = NestedDeviceSerializer(read_only=True)
@@ -246,7 +253,10 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
         default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT')
     )
     legend_width = serializers.IntegerField(
-        default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT
+        default=RACK_ELEVATION_DEFAULT_LEGEND_WIDTH
+    )
+    margin_width = serializers.IntegerField(
+        default=RACK_ELEVATION_DEFAULT_MARGIN_WIDTH
     )
     exclude = serializers.IntegerField(
         required=False,
@@ -283,6 +293,13 @@ class ManufacturerSerializer(NetBoxModelSerializer):
 class DeviceTypeSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
     manufacturer = NestedManufacturerSerializer()
+    u_height = serializers.DecimalField(
+        max_digits=4,
+        decimal_places=1,
+        label='Position (U)',
+        min_value=decimal.Decimal(0.5),
+        default=1.0
+    )
     subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False)
     airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
     device_count = serializers.IntegerField(read_only=True)
@@ -589,7 +606,14 @@ class DeviceSerializer(NetBoxModelSerializer):
     location = NestedLocationSerializer(required=False, allow_null=True, default=None)
     rack = NestedRackSerializer(required=False, allow_null=True, default=None)
     face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, default='')
-    position = serializers.IntegerField(allow_null=True, label='Position (U)', min_value=1, default=None)
+    position = serializers.DecimalField(
+        max_digits=4,
+        decimal_places=1,
+        allow_null=True,
+        label='Position (U)',
+        min_value=decimal.Decimal(0.5),
+        default=None
+    )
     status = ChoiceField(choices=DeviceStatusChoices, required=False)
     airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
     primary_ip = NestedIPAddressSerializer(read_only=True)
@@ -789,6 +813,8 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
     duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True)
     rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True)
     rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True)
+    poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True)
+    poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True)
     untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
     tagged_vlans = SerializedPKRelatedField(
         queryset=VLAN.objects.all(),
@@ -813,10 +839,10 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
         fields = [
             'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag',
             'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel',
-            'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected',
-            'cable', 'wireless_link', 'link_peer', 'link_peer_type', 'wireless_lans', 'vrf', 'connected_endpoint',
-            'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created',
-            'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
+            'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan',
+            'tagged_vlans', 'mark_connected', 'cable', 'wireless_link', 'link_peer', 'link_peer_type', 'wireless_lans',
+            'vrf', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags',
+            'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
         ]
 
     def validate(self, data):

+ 2 - 0
netbox/dcim/api/views.py

@@ -19,6 +19,7 @@ from ipam.models import Prefix, VLAN
 from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.exceptions import ServiceUnavailable
 from netbox.api.metadata import ContentTypeMetadata
+from netbox.api.pagination import StripCountAnnotationsPaginator
 from netbox.api.viewsets import NetBoxModelViewSet
 from netbox.config import get_config
 from utilities.api import get_serializer_for_model
@@ -392,6 +393,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
         'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags',
     )
     filterset_class = filtersets.DeviceFilterSet
+    pagination_class = StripCountAnnotationsPaginator
 
     def get_serializer_class(self):
         """

+ 78 - 0
netbox/dcim/choices.py

@@ -23,6 +23,28 @@ class SiteStatusChoices(ChoiceSet):
     ]
 
 
+#
+# Locations
+#
+
+class LocationStatusChoices(ChoiceSet):
+    key = 'Location.status'
+
+    STATUS_PLANNED = 'planned'
+    STATUS_STAGING = 'staging'
+    STATUS_ACTIVE = 'active'
+    STATUS_DECOMMISSIONING = 'decommissioning'
+    STATUS_RETIRED = 'retired'
+
+    CHOICES = [
+        (STATUS_PLANNED, 'Planned', 'cyan'),
+        (STATUS_STAGING, 'Staging', 'blue'),
+        (STATUS_ACTIVE, 'Active', 'green'),
+        (STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'),
+        (STATUS_RETIRED, 'Retired', 'red'),
+    ]
+
+
 #
 # Racks
 #
@@ -354,6 +376,7 @@ class PowerPortTypeChoices(ChoiceSet):
     TYPE_UBIQUITI_SMARTPOWER = 'ubiquiti-smartpower'
     # Other
     TYPE_HARDWIRED = 'hardwired'
+    TYPE_OTHER = 'other'
 
     CHOICES = (
         ('IEC 60320', (
@@ -471,6 +494,7 @@ class PowerPortTypeChoices(ChoiceSet):
         )),
         ('Other', (
             (TYPE_HARDWIRED, 'Hardwired'),
+            (TYPE_OTHER, 'Other'),
         )),
     )
 
@@ -580,6 +604,7 @@ class PowerOutletTypeChoices(ChoiceSet):
     TYPE_UBIQUITI_SMARTPOWER = 'ubiquiti-smartpower'
     # Other
     TYPE_HARDWIRED = 'hardwired'
+    TYPE_OTHER = 'other'
 
     CHOICES = (
         ('IEC 60320', (
@@ -690,6 +715,7 @@ class PowerOutletTypeChoices(ChoiceSet):
         )),
         ('Other', (
             (TYPE_HARDWIRED, 'Hardwired'),
+            (TYPE_OTHER, 'Other'),
         )),
     )
 
@@ -999,6 +1025,51 @@ class InterfaceModeChoices(ChoiceSet):
     )
 
 
+class InterfacePoEModeChoices(ChoiceSet):
+
+    MODE_PD = 'pd'
+    MODE_PSE = 'pse'
+
+    CHOICES = (
+        (MODE_PD, 'Powered device (PD)'),
+        (MODE_PSE, 'Power sourcing equipment (PSE)'),
+    )
+
+
+class InterfacePoETypeChoices(ChoiceSet):
+
+    TYPE_1_8023AF = 'type1-ieee802.3af'
+    TYPE_2_8023AT = 'type2-ieee802.3at'
+    TYPE_3_8023BT = 'type3-ieee802.3bt'
+    TYPE_4_8023BT = 'type4-ieee802.3bt'
+
+    PASSIVE_24V_2PAIR = 'passive-24v-2pair'
+    PASSIVE_24V_4PAIR = 'passive-24v-4pair'
+    PASSIVE_48V_2PAIR = 'passive-48v-2pair'
+    PASSIVE_48V_4PAIR = 'passive-48v-4pair'
+
+    CHOICES = (
+        (
+            'IEEE Standard',
+            (
+                (TYPE_1_8023AF, '802.3af (Type 1)'),
+                (TYPE_2_8023AT, '802.3at (Type 2)'),
+                (TYPE_3_8023BT, '802.3bt (Type 3)'),
+                (TYPE_4_8023BT, '802.3bt (Type 4)'),
+            )
+        ),
+        (
+            'Passive',
+            (
+                (PASSIVE_24V_2PAIR, 'Passive 24V (2-pair)'),
+                (PASSIVE_24V_4PAIR, 'Passive 24V (4-pair)'),
+                (PASSIVE_48V_2PAIR, 'Passive 48V (2-pair)'),
+                (PASSIVE_48V_2PAIR, 'Passive 48V (4-pair)'),
+            )
+        ),
+    )
+
+
 #
 # FrontPorts/RearPorts
 #
@@ -1047,6 +1118,7 @@ class PortTypeChoices(ChoiceSet):
     TYPE_URM_P2 = 'urm-p2'
     TYPE_URM_P4 = 'urm-p4'
     TYPE_URM_P8 = 'urm-p8'
+    TYPE_OTHER = 'other'
 
     CHOICES = (
         (
@@ -1099,6 +1171,12 @@ class PortTypeChoices(ChoiceSet):
                 (TYPE_URM_P4, 'URM-P4'),
                 (TYPE_URM_P8, 'URM-P8'),
                 (TYPE_SPLICE, 'Splice'),
+            ),
+        ),
+        (
+            'Other',
+            (
+                (TYPE_OTHER, 'Other'),
             )
         )
     )

+ 2 - 1
netbox/dcim/constants.py

@@ -13,7 +13,8 @@ DEVICETYPE_IMAGE_FORMATS = 'image/bmp,image/gif,image/jpeg,image/png,image/tiff,
 RACK_U_HEIGHT_DEFAULT = 42
 
 RACK_ELEVATION_BORDER_WIDTH = 2
-RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30
+RACK_ELEVATION_DEFAULT_LEGEND_WIDTH = 30
+RACK_ELEVATION_DEFAULT_MARGIN_WIDTH = 15
 
 
 #

+ 14 - 4
netbox/dcim/filtersets.py

@@ -163,7 +163,7 @@ class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
             qs_filter |= Q(asns__asn=int(value.strip()))
         except ValueError:
             pass
-        return queryset.filter(qs_filter)
+        return queryset.filter(qs_filter).distinct()
 
 
 class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalModelFilterSet):
@@ -216,10 +216,14 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM
         to_field_name='slug',
         label='Location (slug)',
     )
+    status = django_filters.MultipleChoiceFilter(
+        choices=LocationStatusChoices,
+        null_value=None
+    )
 
     class Meta:
         model = Location
-        fields = ['id', 'name', 'slug', 'description']
+        fields = ['id', 'name', 'slug', 'status', 'description']
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -1238,6 +1242,12 @@ class InterfaceFilterSet(
     )
     mac_address = MultiValueMACAddressFilter()
     wwn = MultiValueWWNFilter()
+    poe_mode = django_filters.MultipleChoiceFilter(
+        choices=InterfacePoEModeChoices
+    )
+    poe_type = django_filters.MultipleChoiceFilter(
+        choices=InterfacePoETypeChoices
+    )
     vlan_id = django_filters.CharFilter(
         method='filter_vlan_id',
         label='Assigned VLAN'
@@ -1271,8 +1281,8 @@ class InterfaceFilterSet(
     class Meta:
         model = Interface
         fields = [
-            'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'rf_role', 'rf_channel',
-            'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description',
+            'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'poe_mode', 'poe_type', 'mode', 'rf_role',
+            'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description',
         ]
 
     def filter_device(self, queryset, name, value):

+ 5 - 2
netbox/dcim/forms/bulk_create.py

@@ -72,12 +72,15 @@ class PowerOutletBulkCreateForm(
 
 
 class InterfaceBulkCreateForm(
-    form_from_model(Interface, ['type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected']),
+    form_from_model(Interface, [
+        'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'poe_mode', 'poe_type',
+    ]),
     DeviceBulkAddComponentForm
 ):
     model = Interface
     field_order = (
-        'name_pattern', 'label_pattern', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'tags',
+        'name_pattern', 'label_pattern', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'poe_mode',
+        'poe_type', 'mark_connected', 'description', 'tags',
     )
 
 

+ 45 - 7
netbox/dcim/forms/bulk_edit.py

@@ -6,7 +6,7 @@ from timezone_field import TimeZoneFormField
 from dcim.choices import *
 from dcim.constants import *
 from dcim.models import *
-from ipam.models import ASN, VLAN, VRF
+from ipam.models import ASN, VLAN, VLANGroup, VRF
 from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import Tenant
 from utilities.forms import (
@@ -158,6 +158,12 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm):
             'site_id': '$site'
         }
     )
+    status = forms.ChoiceField(
+        choices=add_blank_choice(LocationStatusChoices),
+        required=False,
+        initial='',
+        widget=StaticSelect()
+    )
     tenant = DynamicModelChoiceField(
         queryset=Tenant.objects.all(),
         required=False
@@ -169,7 +175,7 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm):
 
     model = Location
     fieldsets = (
-        (None, ('site', 'parent', 'tenant', 'description')),
+        (None, ('site', 'parent', 'status', 'tenant', 'description')),
     )
     nullable_fields = ('parent', 'tenant', 'description')
 
@@ -1063,17 +1069,48 @@ class InterfaceBulkEditForm(
         widget=BulkEditNullBooleanSelect,
         label='Management only'
     )
+    poe_mode = forms.ChoiceField(
+        choices=add_blank_choice(InterfacePoEModeChoices),
+        required=False,
+        initial='',
+        widget=StaticSelect()
+    )
+    poe_type = forms.ChoiceField(
+        choices=add_blank_choice(InterfacePoETypeChoices),
+        required=False,
+        initial='',
+        widget=StaticSelect()
+    )
     mark_connected = forms.NullBooleanField(
         required=False,
         widget=BulkEditNullBooleanSelect
     )
+    mode = forms.ChoiceField(
+        choices=add_blank_choice(InterfaceModeChoices),
+        required=False,
+        initial='',
+        widget=StaticSelect()
+    )
+    vlan_group = DynamicModelChoiceField(
+        queryset=VLANGroup.objects.all(),
+        required=False,
+        label='VLAN group'
+    )
     untagged_vlan = DynamicModelChoiceField(
         queryset=VLAN.objects.all(),
-        required=False
+        required=False,
+        query_params={
+            'group_id': '$vlan_group',
+        },
+        label='Untagged VLAN'
     )
     tagged_vlans = DynamicModelMultipleChoiceField(
         queryset=VLAN.objects.all(),
-        required=False
+        required=False,
+        query_params={
+            'group_id': '$vlan_group',
+        },
+        label='Tagged VLANs'
     )
     vrf = DynamicModelChoiceField(
         queryset=VRF.objects.all(),
@@ -1086,14 +1123,15 @@ class InterfaceBulkEditForm(
         (None, ('module', 'type', 'label', 'speed', 'duplex', 'description')),
         ('Addressing', ('vrf', 'mac_address', 'wwn')),
         ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
+        ('PoE', ('poe_mode', 'poe_type')),
         ('Related Interfaces', ('parent', 'bridge', 'lag')),
-        ('802.1Q Switching', ('mode', 'untagged_vlan', 'tagged_vlans')),
+        ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
         ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')),
     )
     nullable_fields = (
         'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description',
-        'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans',
-        'vrf',
+        'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
+        'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf',
     )
 
     def __init__(self, *args, **kwargs):

+ 18 - 4
netbox/dcim/forms/bulk_import.py

@@ -124,6 +124,10 @@ class LocationCSVForm(NetBoxModelCSVForm):
             'invalid_choice': 'Location not found.',
         }
     )
+    status = CSVChoiceField(
+        choices=LocationStatusChoices,
+        help_text='Operational status'
+    )
     tenant = CSVModelChoiceField(
         queryset=Tenant.objects.all(),
         required=False,
@@ -133,7 +137,7 @@ class LocationCSVForm(NetBoxModelCSVForm):
 
     class Meta:
         model = Location
-        fields = ('site', 'parent', 'name', 'slug', 'tenant', 'description')
+        fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description')
 
 
 class RackRoleCSVForm(NetBoxModelCSVForm):
@@ -622,6 +626,16 @@ class InterfaceCSVForm(NetBoxModelCSVForm):
         choices=InterfaceDuplexChoices,
         required=False
     )
+    poe_mode = CSVChoiceField(
+        choices=InterfacePoEModeChoices,
+        required=False,
+        help_text='PoE mode'
+    )
+    poe_type = CSVChoiceField(
+        choices=InterfacePoETypeChoices,
+        required=False,
+        help_text='PoE type'
+    )
     mode = CSVChoiceField(
         choices=InterfaceModeChoices,
         required=False,
@@ -642,9 +656,9 @@ class InterfaceCSVForm(NetBoxModelCSVForm):
     class Meta:
         model = Interface
         fields = (
-            'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled', 'mark_connected', 'mac_address',
-            'wwn', 'mtu', 'mgmt_only', 'description', 'mode', 'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency',
-            'rf_channel_width', 'tx_power',
+            'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled',
+            'mark_connected', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode',
+            'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
         )
 
     def __init__(self, data=None, *args, **kwargs):

+ 23 - 10
netbox/dcim/forms/filtersets.py

@@ -108,7 +108,7 @@ class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = Region
     fieldsets = (
         (None, ('q', 'tag', 'parent_id')),
-        ('Contacts', ('contact', 'contact_role'))
+        ('Contacts', ('contact', 'contact_role', 'contact_group'))
     )
     parent_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
@@ -122,7 +122,7 @@ class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = SiteGroup
     fieldsets = (
         (None, ('q', 'tag', 'parent_id')),
-        ('Contacts', ('contact', 'contact_role'))
+        ('Contacts', ('contact', 'contact_role', 'contact_group'))
     )
     parent_id = DynamicModelMultipleChoiceField(
         queryset=SiteGroup.objects.all(),
@@ -138,7 +138,7 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
         (None, ('q', 'tag')),
         ('Attributes', ('status', 'region_id', 'group_id', 'asn_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
-        ('Contacts', ('contact', 'contact_role')),
+        ('Contacts', ('contact', 'contact_role', 'contact_group')),
     )
     status = MultipleChoiceField(
         choices=SiteStatusChoices,
@@ -166,9 +166,9 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF
     model = Location
     fieldsets = (
         (None, ('q', 'tag')),
-        ('Parent', ('region_id', 'site_group_id', 'site_id', 'parent_id')),
+        ('Attributes', ('region_id', 'site_group_id', 'site_id', 'parent_id', 'status')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
-        ('Contacts', ('contact', 'contact_role')),
+        ('Contacts', ('contact', 'contact_role', 'contact_group')),
     )
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
@@ -198,6 +198,10 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF
         },
         label=_('Parent')
     )
+    status = MultipleChoiceField(
+        choices=LocationStatusChoices,
+        required=False
+    )
     tag = TagFilterField(model)
 
 
@@ -214,7 +218,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
         ('Function', ('status', 'role_id')),
         ('Hardware', ('type', 'width', 'serial', 'asset_tag')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
-        ('Contacts', ('contact', 'contact_role')),
+        ('Contacts', ('contact', 'contact_role', 'contact_group')),
     )
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
@@ -329,7 +333,7 @@ class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = Manufacturer
     fieldsets = (
         (None, ('q', 'tag')),
-        ('Contacts', ('contact', 'contact_role'))
+        ('Contacts', ('contact', 'contact_role', 'contact_group'))
     )
     tag = TagFilterField(model)
 
@@ -518,7 +522,7 @@ class DeviceFilterForm(
         ('Operation', ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')),
         ('Hardware', ('manufacturer_id', 'device_type_id', 'platform_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
-        ('Contacts', ('contact', 'contact_role')),
+        ('Contacts', ('contact', 'contact_role', 'contact_group')),
         ('Components', (
             'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
         )),
@@ -788,7 +792,7 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
     fieldsets = (
         (None, ('q', 'tag')),
         ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
-        ('Contacts', ('contact', 'contact_role')),
+        ('Contacts', ('contact', 'contact_role', 'contact_group')),
     )
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
@@ -969,6 +973,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
         (None, ('q', 'tag')),
         ('Attributes', ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')),
         ('Addressing', ('vrf_id', 'mac_address', 'wwn')),
+        ('PoE', ('poe_mode', 'poe_type')),
         ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')),
     )
@@ -1009,6 +1014,14 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
         required=False,
         label='WWN'
     )
+    poe_mode = MultipleChoiceField(
+        choices=InterfacePoEModeChoices,
+        required=False
+    )
+    poe_type = MultipleChoiceField(
+        choices=InterfacePoEModeChoices,
+        required=False
+    )
     rf_role = MultipleChoiceField(
         choices=WirelessRoleChoices,
         required=False,
@@ -1102,7 +1115,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
     model = InventoryItem
     fieldsets = (
         (None, ('q', 'tag')),
-        ('Attributes', ('name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
+        ('Attributes', ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')),
     )
     role_id = DynamicModelMultipleChoiceField(

+ 13 - 6
netbox/dcim/forms/models.py

@@ -194,7 +194,7 @@ class LocationForm(TenancyForm, NetBoxModelForm):
 
     fieldsets = (
         ('Location', (
-            'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tags',
+            'region', 'site_group', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tags',
         )),
         ('Tenancy', ('tenant_group', 'tenant')),
     )
@@ -202,8 +202,12 @@ class LocationForm(TenancyForm, NetBoxModelForm):
     class Meta:
         model = Location
         fields = (
-            'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tenant_group', 'tenant', 'tags',
+            'region', 'site_group', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant',
+            'tags',
         )
+        widgets = {
+            'status': StaticSelect(),
+        }
 
 
 class RackRoleForm(NetBoxModelForm):
@@ -467,7 +471,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
             'location_id': '$location',
         }
     )
-    position = forms.IntegerField(
+    position = forms.DecimalField(
         required=False,
         help_text="The lowest-numbered unit occupied by the device",
         widget=APISelect(
@@ -1314,6 +1318,7 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
         ('Addressing', ('vrf', 'mac_address', 'wwn')),
         ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
         ('Related Interfaces', ('parent', 'bridge', 'lag')),
+        ('PoE', ('poe_mode', 'poe_type')),
         ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
         ('Wireless', (
             'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans',
@@ -1324,14 +1329,16 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
         model = Interface
         fields = [
             'device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag',
-            'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel',
-            'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans',
-            'vrf', 'tags',
+            'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'poe_mode', 'poe_type', 'mode',
+            'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans',
+            'untagged_vlan', 'tagged_vlans', 'vrf', 'tags',
         ]
         widgets = {
             'device': forms.HiddenInput(),
             'type': StaticSelect(),
             'speed': SelectSpeedWidget(),
+            'poe_mode': StaticSelect(),
+            'poe_type': StaticSelect(),
             'duplex': StaticSelect(),
             'mode': StaticSelect(),
             'rf_role': StaticSelect(),

+ 2 - 0
netbox/dcim/forms/object_create.py

@@ -256,6 +256,8 @@ class VirtualChassisCreateForm(NetBoxModelForm):
         ]
 
     def clean(self):
+        super().clean()
+
         if self.cleaned_data['members'] and self.cleaned_data['initial_position'] is None:
             raise forms.ValidationError({
                 'initial_position': "A position must be specified for the first VC member."

+ 6 - 0
netbox/dcim/graphql/types.py

@@ -226,6 +226,12 @@ class InterfaceType(IPAddressesMixin, ComponentObjectType):
         exclude = ('_path',)
         filterset_class = filtersets.InterfaceFilterSet
 
+    def resolve_poe_mode(self, info):
+        return self.poe_mode or None
+
+    def resolve_poe_type(self, info):
+        return self.poe_type or None
+
     def resolve_mode(self, info):
         return self.mode or None
 

+ 23 - 0
netbox/dcim/migrations/0154_half_height_rack_units.py

@@ -0,0 +1,23 @@
+import django.contrib.postgres.fields
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0153_created_datetimefield'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='devicetype',
+            name='u_height',
+            field=models.DecimalField(decimal_places=1, default=1.0, max_digits=4),
+        ),
+        migrations.AlterField(
+            model_name='device',
+            name='position',
+            field=models.DecimalField(blank=True, decimal_places=1, max_digits=4, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(99.5)]),
+        ),
+    ]

+ 23 - 0
netbox/dcim/migrations/0155_interface_poe_mode_type.py

@@ -0,0 +1,23 @@
+# Generated by Django 4.0.5 on 2022-06-22 00:36
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0154_half_height_rack_units'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='interface',
+            name='poe_mode',
+            field=models.CharField(blank=True, max_length=50),
+        ),
+        migrations.AddField(
+            model_name='interface',
+            name='poe_type',
+            field=models.CharField(blank=True, max_length=50),
+        ),
+    ]

+ 18 - 0
netbox/dcim/migrations/0156_location_status.py

@@ -0,0 +1,18 @@
+# Generated by Django 4.0.5 on 2022-06-22 17:10
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0155_interface_poe_mode_type'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='location',
+            name='status',
+            field=models.CharField(default='active', max_length=50),
+        ),
+    ]

+ 31 - 1
netbox/dcim/models/device_components.py

@@ -590,6 +590,18 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
         validators=(MaxValueValidator(127),),
         verbose_name='Transmit power (dBm)'
     )
+    poe_mode = models.CharField(
+        max_length=50,
+        choices=InterfacePoEModeChoices,
+        blank=True,
+        verbose_name='PoE mode'
+    )
+    poe_type = models.CharField(
+        max_length=50,
+        choices=InterfacePoETypeChoices,
+        blank=True,
+        verbose_name='PoE type'
+    )
     wireless_link = models.ForeignKey(
         to='wireless.WirelessLink',
         on_delete=models.SET_NULL,
@@ -638,7 +650,7 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
         related_query_name='+'
     )
 
-    clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only']
+    clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only', 'poe_mode', 'poe_type']
 
     class Meta:
         ordering = ('device', CollateAsChar('_name'))
@@ -726,6 +738,24 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
                            f"of virtual chassis {self.device.virtual_chassis}."
                 })
 
+        # PoE validation
+
+        # Only physical interfaces may have a PoE mode/type assigned
+        if self.poe_mode and self.is_virtual:
+            raise ValidationError({
+                'poe_mode': "Virtual interfaces cannot have a PoE mode."
+            })
+        if self.poe_type and self.is_virtual:
+            raise ValidationError({
+                'poe_type': "Virtual interfaces cannot have a PoE type."
+            })
+
+        # An interface with a PoE type set must also specify a mode
+        if self.poe_type and not self.poe_mode:
+            raise ValidationError({
+                'poe_type': "Must specify PoE mode when designating a PoE type."
+            })
+
         # Wireless validation
 
         # RF role & channel may only be set for wireless interfaces

+ 13 - 5
netbox/dcim/models/devices.py

@@ -99,8 +99,10 @@ class DeviceType(NetBoxModel):
         blank=True,
         help_text='Discrete part number (optional)'
     )
-    u_height = models.PositiveSmallIntegerField(
-        default=1,
+    u_height = models.DecimalField(
+        max_digits=4,
+        decimal_places=1,
+        default=1.0,
         verbose_name='Height (U)'
     )
     is_full_depth = models.BooleanField(
@@ -166,7 +168,7 @@ class DeviceType(NetBoxModel):
             ('model', self.model),
             ('slug', self.slug),
             ('part_number', self.part_number),
-            ('u_height', self.u_height),
+            ('u_height', float(self.u_height)),
             ('is_full_depth', self.is_full_depth),
             ('subdevice_role', self.subdevice_role),
             ('airflow', self.airflow),
@@ -654,10 +656,12 @@ class Device(NetBoxModel, ConfigContextModel):
         blank=True,
         null=True
     )
-    position = models.PositiveSmallIntegerField(
+    position = models.DecimalField(
+        max_digits=4,
+        decimal_places=1,
         blank=True,
         null=True,
-        validators=[MinValueValidator(1)],
+        validators=[MinValueValidator(1), MaxValueValidator(99.5)],
         verbose_name='Position (U)',
         help_text='The lowest-numbered unit occupied by the device'
     )
@@ -748,8 +752,12 @@ class Device(NetBoxModel, ConfigContextModel):
             return f'{self.name} ({self.asset_tag})'
         elif self.name:
             return self.name
+        elif self.virtual_chassis and self.asset_tag:
+            return f'{self.virtual_chassis.name}:{self.vc_position} ({self.asset_tag})'
         elif self.virtual_chassis:
             return f'{self.virtual_chassis.name}:{self.vc_position} ({self.pk})'
+        elif self.device_type and self.asset_tag:
+            return f'{self.device_type.manufacturer} {self.device_type.model} ({self.asset_tag})'
         elif self.device_type:
             return f'{self.device_type.manufacturer} {self.device_type.model} ({self.pk})'
         return super().__str__()

+ 37 - 29
netbox/dcim/models/racks.py

@@ -1,4 +1,4 @@
-from collections import OrderedDict
+import decimal
 
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.fields import GenericRelation
@@ -13,11 +13,10 @@ from django.urls import reverse
 from dcim.choices import *
 from dcim.constants import *
 from dcim.svg import RackElevationSVG
-from netbox.config import get_config
 from netbox.models import OrganizationalModel, NetBoxModel
 from utilities.choices import ColorChoices
 from utilities.fields import ColorField, NaturalOrderingField
-from utilities.utils import array_to_string
+from utilities.utils import array_to_string, drange
 from .device_components import PowerOutlet, PowerPort
 from .devices import Device
 from .power import PowerFeed
@@ -242,10 +241,13 @@ class Rack(NetBoxModel):
 
     @property
     def units(self):
+        """
+        Return a list of unit numbers, top to bottom.
+        """
+        max_position = self.u_height + decimal.Decimal(0.5)
         if self.desc_units:
-            return range(1, self.u_height + 1)
-        else:
-            return reversed(range(1, self.u_height + 1))
+            drange(0.5, max_position, 0.5)
+        return drange(max_position, 0.5, -0.5)
 
     def get_status_color(self):
         return RackStatusChoices.colors.get(self.status)
@@ -263,12 +265,12 @@ class Rack(NetBoxModel):
             reference to the device. When False, only the bottom most unit for a device is included and that unit
             contains a height attribute for the device
         """
-
-        elevation = OrderedDict()
+        elevation = {}
         for u in self.units:
+            u_name = f'U{u}'.split('.')[0] if not u % 1 else f'U{u}'
             elevation[u] = {
                 'id': u,
-                'name': f'U{u}',
+                'name': u_name,
                 'face': face,
                 'device': None,
                 'occupied': False
@@ -278,7 +280,7 @@ class Rack(NetBoxModel):
         if self.pk:
 
             # Retrieve all devices installed within the rack
-            queryset = Device.objects.prefetch_related(
+            devices = Device.objects.prefetch_related(
                 'device_type',
                 'device_type__manufacturer',
                 'device_role'
@@ -299,9 +301,9 @@ class Rack(NetBoxModel):
             if user is not None:
                 permitted_device_ids = self.devices.restrict(user, 'view').values_list('pk', flat=True)
 
-            for device in queryset:
+            for device in devices:
                 if expand_devices:
-                    for u in range(device.position, device.position + device.device_type.u_height):
+                    for u in drange(device.position, device.position + device.device_type.u_height, 0.5):
                         if user is None or device.pk in permitted_device_ids:
                             elevation[u]['device'] = device
                         elevation[u]['occupied'] = True
@@ -310,8 +312,6 @@ class Rack(NetBoxModel):
                         elevation[device.position]['device'] = device
                     elevation[device.position]['occupied'] = True
                     elevation[device.position]['height'] = device.device_type.u_height
-                    for u in range(device.position + 1, device.position + device.device_type.u_height):
-                        elevation.pop(u, None)
 
         return [u for u in elevation.values()]
 
@@ -331,12 +331,12 @@ class Rack(NetBoxModel):
             devices = devices.exclude(pk__in=exclude)
 
         # Initialize the rack unit skeleton
-        units = list(range(1, self.u_height + 1))
+        units = list(self.units)
 
         # Remove units consumed by installed devices
         for d in devices:
             if rack_face is None or d.face == rack_face or d.device_type.is_full_depth:
-                for u in range(d.position, d.position + d.device_type.u_height):
+                for u in drange(d.position, d.position + d.device_type.u_height, 0.5):
                     try:
                         units.remove(u)
                     except ValueError:
@@ -346,7 +346,7 @@ class Rack(NetBoxModel):
         # Remove units without enough space above them to accommodate a device of the specified height
         available_units = []
         for u in units:
-            if set(range(u, u + u_height)).issubset(units):
+            if set(drange(u, u + u_height, 0.5)).issubset(units):
                 available_units.append(u)
 
         return list(reversed(available_units))
@@ -356,9 +356,9 @@ class Rack(NetBoxModel):
         Return a dictionary mapping all reserved units within the rack to their reservation.
         """
         reserved_units = {}
-        for r in self.reservations.all():
-            for u in r.units:
-                reserved_units[u] = r
+        for reservation in self.reservations.all():
+            for u in reservation.units:
+                reserved_units[u] = reservation
         return reserved_units
 
     def get_elevation_svg(
@@ -367,7 +367,8 @@ class Rack(NetBoxModel):
             user=None,
             unit_width=None,
             unit_height=None,
-            legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT,
+            legend_width=RACK_ELEVATION_DEFAULT_LEGEND_WIDTH,
+            margin_width=RACK_ELEVATION_DEFAULT_MARGIN_WIDTH,
             include_images=True,
             base_url=None
     ):
@@ -381,16 +382,22 @@ class Rack(NetBoxModel):
         :param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total
             height of the elevation
         :param legend_width: Width of the unit legend, in pixels
+        :param margin_width: Width of the rigth-hand margin, in pixels
         :param include_images: Embed front/rear device images where available
         :param base_url: Base URL for links and images. If none, URLs will be relative.
         """
-        elevation = RackElevationSVG(self, user=user, include_images=include_images, base_url=base_url)
-        if unit_width is None or unit_height is None:
-            config = get_config()
-            unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH
-            unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT
+        elevation = RackElevationSVG(
+            self,
+            unit_width=unit_width,
+            unit_height=unit_height,
+            legend_width=legend_width,
+            margin_width=margin_width,
+            user=user,
+            include_images=include_images,
+            base_url=base_url
+        )
 
-        return elevation.render(face, unit_width, unit_height, legend_width)
+        return elevation.render(face)
 
     def get_0u_devices(self):
         return self.devices.filter(position=0)
@@ -401,6 +408,7 @@ class Rack(NetBoxModel):
         as utilized.
         """
         # Determine unoccupied units
+        total_units = len(list(self.units))
         available_units = self.get_available_units()
 
         # Remove reserved units
@@ -408,8 +416,8 @@ class Rack(NetBoxModel):
             if u in available_units:
                 available_units.remove(u)
 
-        occupied_unit_count = self.u_height - len(available_units)
-        percentage = float(occupied_unit_count) / self.u_height * 100
+        occupied_unit_count = total_units - len(available_units)
+        percentage = float(occupied_unit_count) / total_units * 100
 
         return percentage
 

+ 9 - 1
netbox/dcim/models/sites.py

@@ -341,6 +341,11 @@ class Location(NestedGroupModel):
         null=True,
         db_index=True
     )
+    status = models.CharField(
+        max_length=50,
+        choices=LocationStatusChoices,
+        default=LocationStatusChoices.STATUS_ACTIVE
+    )
     tenant = models.ForeignKey(
         to='tenancy.Tenant',
         on_delete=models.PROTECT,
@@ -367,7 +372,7 @@ class Location(NestedGroupModel):
         to='extras.ImageAttachment'
     )
 
-    clone_fields = ['site', 'parent', 'tenant', 'description']
+    clone_fields = ['site', 'parent', 'status', 'tenant', 'description']
 
     class Meta:
         ordering = ['site', 'name']
@@ -409,6 +414,9 @@ class Location(NestedGroupModel):
     def get_absolute_url(self):
         return reverse('dcim:location', args=[self.pk])
 
+    def get_status_color(self):
+        return LocationStatusChoices.colors.get(self.status)
+
     def clean(self):
         super().clean()
 

+ 2 - 0
netbox/dcim/svg/__init__.py

@@ -0,0 +1,2 @@
+from .cables import *
+from .racks import *

+ 2 - 257
netbox/dcim/svg.py → netbox/dcim/svg/cables.py

@@ -1,272 +1,17 @@
 import svgwrite
+
+from django.conf import settings
 from svgwrite.container import Group, Hyperlink
 from svgwrite.shapes import Line, Rect
 from svgwrite.text import Text
 
-from django.conf import settings
-from django.urls import reverse
-from django.utils.http import urlencode
-
 from utilities.utils import foreground_color
-from .choices import DeviceFaceChoices
-from .constants import RACK_ELEVATION_BORDER_WIDTH
-
 
 __all__ = (
     'CableTraceSVG',
-    'RackElevationSVG',
 )
 
 
-def get_device_name(device):
-    if device.virtual_chassis:
-        return f'{device.virtual_chassis.name}:{device.vc_position}'
-    elif device.name:
-        return device.name
-    else:
-        return str(device.device_type)
-
-
-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, user=None, include_images=True, base_url=None):
-        self.rack = rack
-        self.include_images = include_images
-        if base_url is not None:
-            self.base_url = base_url.rstrip('/')
-        else:
-            self.base_url = ''
-
-        # 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,
-            device.device_type.manufacturer.name,
-            device.device_type.model,
-            device.device_type.u_height,
-            device.asset_tag or '',
-            device.serial or ''
-        )
-
-    @staticmethod
-    def _add_gradient(drawing, id_, color):
-        gradient = drawing.linearGradient(
-            start=(0, 0),
-            end=(0, 25),
-            spreadMethod='repeat',
-            id_=id_,
-            gradientTransform='rotate(45, 0, 0)',
-            gradientUnits='userSpaceOnUse'
-        )
-        gradient.add_stop_color(offset='0%', color='#f7f7f7')
-        gradient.add_stop_color(offset='50%', color='#f7f7f7')
-        gradient.add_stop_color(offset='50%', color=color)
-        gradient.add_stop_color(offset='100%', color=color)
-        drawing.defs.add(gradient)
-
-    @staticmethod
-    def _setup_drawing(width, height):
-        drawing = svgwrite.Drawing(size=(width, height))
-
-        # add the stylesheet
-        with open('{}/rack_elevation.css'.format(settings.STATIC_ROOT)) as css_file:
-            drawing.defs.add(drawing.style(css_file.read()))
-
-        # add gradients
-        RackElevationSVG._add_gradient(drawing, 'reserved', '#c7c7ff')
-        RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7')
-        RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0')
-
-        return drawing
-
-    def _draw_device_front(self, drawing, device, start, end, text):
-        name = get_device_name(device)
-        if device.devicebay_count:
-            name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count)
-
-        color = device.device_role.color
-        link = drawing.add(
-            drawing.a(
-                href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})),
-                target='_top',
-                fill='black'
-            )
-        )
-        link.set_desc(self._get_device_description(device))
-        link.add(drawing.rect(start, end, style='fill: #{}'.format(color), class_='slot'))
-        hex_color = '#{}'.format(foreground_color(color))
-        link.add(drawing.text(str(name), insert=text, fill=hex_color))
-
-        # Embed front device type image if one exists
-        if self.include_images and device.device_type.front_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)
-            link.add(drawing.text(str(name), insert=text, stroke='black',
-                     stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
-            link.add(drawing.text(str(name), insert=text, fill='white', class_='device-image-label'))
-
-    def _draw_device_rear(self, drawing, device, start, end, text):
-        link = drawing.add(
-            drawing.a(
-                href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})),
-                target='_top',
-                fill='black'
-            )
-        )
-        link.set_desc(self._get_device_description(device))
-        link.add(drawing.rect(start, end, class_="slot blocked"))
-        link.add(drawing.text(get_device_name(device), insert=text))
-
-        # Embed rear device type image if one exists
-        if self.include_images and device.device_type.rear_image:
-            image = drawing.image(
-                href=device.device_type.rear_image.url,
-                insert=start,
-                size=end,
-                class_='device-image'
-            )
-            image.fit(scale='slice')
-            link.add(image)
-            link.add(drawing.text(get_device_name(device), insert=text, stroke='black',
-                     stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
-            link.add(drawing.text(get_device_name(device), insert=text, fill='white', class_='device-image-label'))
-
-    @staticmethod
-    def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation):
-        link_url = '{}?{}'.format(
-            reverse('dcim:device_add'),
-            urlencode({
-                'site': rack.site.pk,
-                'location': rack.location.pk if rack.location else '',
-                'rack': rack.pk,
-                'face': face_id,
-                'position': id_
-            })
-        )
-        link = drawing.add(
-            drawing.a(href=link_url, target='_top')
-        )
-        if reservation:
-            link.set_desc('{} — {} · {}'.format(
-                reservation.description, reservation.user, reservation.created
-            ))
-        link.add(drawing.rect(start, end, class_=class_))
-        link.add(drawing.text("add device", insert=text, class_='add-device'))
-
-    def merge_elevations(self, face):
-        elevation = self.rack.get_rack_units(face=face, expand_devices=False)
-        if face == DeviceFaceChoices.FACE_REAR:
-            other_face = DeviceFaceChoices.FACE_FRONT
-        else:
-            other_face = DeviceFaceChoices.FACE_REAR
-        other = self.rack.get_rack_units(face=other_face)
-
-        unit_cursor = 0
-        for u in elevation:
-            o = other[unit_cursor]
-            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)
-
-        return elevation
-
-    def render(self, face, unit_width, unit_height, legend_width):
-        """
-        Return an SVG document representing a rack elevation.
-        """
-        drawing = self._setup_drawing(
-            unit_width + legend_width + RACK_ELEVATION_BORDER_WIDTH * 2,
-            unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2
-        )
-        reserved_units = self.rack.get_reserved_units()
-
-        unit_cursor = 0
-        for ru in range(0, self.rack.u_height):
-            start_y = ru * unit_height
-            position_coordinates = (legend_width / 2, start_y + unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH)
-            unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru
-            drawing.add(
-                drawing.text(str(unit), position_coordinates, class_="unit")
-            )
-
-        for unit in self.merge_elevations(face):
-
-            # Loop through all units in the elevation
-            device = unit['device']
-            height = unit.get('height', 1)
-
-            # Setup drawing coordinates
-            x_offset = legend_width + RACK_ELEVATION_BORDER_WIDTH
-            y_offset = unit_cursor * unit_height + RACK_ELEVATION_BORDER_WIDTH
-            end_y = unit_height * height
-            start_cordinates = (x_offset, y_offset)
-            end_cordinates = (unit_width, end_y)
-            text_cordinates = (x_offset + (unit_width / 2), y_offset + end_y / 2)
-
-            # Draw the device
-            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 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'
-                reservation = reserved_units.get(unit["id"])
-                if device:
-                    class_ += ' occupied'
-                if reservation:
-                    class_ += ' reserved'
-                self._draw_empty(
-                    drawing,
-                    self.rack,
-                    start_cordinates,
-                    end_cordinates,
-                    text_cordinates,
-                    unit["id"],
-                    face,
-                    class_,
-                    reservation
-                )
-
-            unit_cursor += height
-
-        # Wrap the drawing with a border
-        border_width = RACK_ELEVATION_BORDER_WIDTH
-        border_offset = RACK_ELEVATION_BORDER_WIDTH / 2
-        frame = drawing.rect(
-            insert=(legend_width + border_offset, border_offset),
-            size=(unit_width + border_width, self.rack.u_height * unit_height + border_width),
-            class_='rack'
-        )
-        drawing.add(frame)
-
-        return drawing
-
-
 OFFSET = 0.5
 PADDING = 10
 LINE_HEIGHT = 20

+ 300 - 0
netbox/dcim/svg/racks.py

@@ -0,0 +1,300 @@
+import decimal
+import svgwrite
+from svgwrite.container import Hyperlink
+from svgwrite.image import Image
+from svgwrite.gradients import LinearGradient
+from svgwrite.shapes import Rect
+from svgwrite.text import Text
+
+from django.conf import settings
+from django.urls import reverse
+from django.utils.http import urlencode
+
+from netbox.config import get_config
+from utilities.utils import foreground_color, array_to_ranges
+from dcim.choices import DeviceFaceChoices
+from dcim.constants import RACK_ELEVATION_BORDER_WIDTH
+
+
+__all__ = (
+    'RackElevationSVG',
+)
+
+
+def get_device_name(device):
+    if device.virtual_chassis:
+        name = f'{device.virtual_chassis.name}:{device.vc_position}'
+    elif device.name:
+        name = device.name
+    else:
+        name = str(device.device_type)
+    if device.devicebay_count:
+        name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count)
+
+    return name
+
+
+def get_device_description(device):
+    return '{} ({}) — {} {} ({}U) {} {}'.format(
+        device.name,
+        device.device_role,
+        device.device_type.manufacturer.name,
+        device.device_type.model,
+        device.device_type.u_height,
+        device.asset_tag or '',
+        device.serial or ''
+    )
+
+
+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, unit_height=None, unit_width=None, legend_width=None, margin_width=None, user=None,
+                 include_images=True, base_url=None):
+        self.rack = rack
+        self.include_images = include_images
+        self.base_url = base_url.rstrip('/') if base_url is not None else ''
+
+        # Set drawing dimensions
+        config = get_config()
+        self.unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH
+        self.unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT
+        self.legend_width = legend_width or config.RACK_ELEVATION_DEFAULT_LEGEND_WIDTH
+        self.margin_width = margin_width or config.RACK_ELEVATION_DEFAULT_MARGIN_WIDTH
+
+        # 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 _add_gradient(drawing, id_, color):
+        gradient = LinearGradient(
+            start=(0, 0),
+            end=(0, 25),
+            spreadMethod='repeat',
+            id_=id_,
+            gradientTransform='rotate(45, 0, 0)',
+            gradientUnits='userSpaceOnUse'
+        )
+        gradient.add_stop_color(offset='0%', color='#f7f7f7')
+        gradient.add_stop_color(offset='50%', color='#f7f7f7')
+        gradient.add_stop_color(offset='50%', color=color)
+        gradient.add_stop_color(offset='100%', color=color)
+
+        drawing.defs.add(gradient)
+
+    def _setup_drawing(self):
+        width = self.unit_width + self.legend_width + self.margin_width + RACK_ELEVATION_BORDER_WIDTH * 2
+        height = self.unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2
+        drawing = svgwrite.Drawing(size=(width, height))
+
+        # Add the stylesheet
+        with open(f'{settings.STATIC_ROOT}/rack_elevation.css') as css_file:
+            drawing.defs.add(drawing.style(css_file.read()))
+
+        # Add gradients
+        RackElevationSVG._add_gradient(drawing, 'reserved', '#b0b0ff')
+        RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7')
+        RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0')
+
+        return drawing
+
+    def _get_device_coords(self, position, height):
+        """
+        Return the X, Y coordinates of the top left corner for a device in the specified rack unit.
+        """
+        x = self.legend_width + RACK_ELEVATION_BORDER_WIDTH
+        y = RACK_ELEVATION_BORDER_WIDTH
+        if self.rack.desc_units:
+            y += int((position - 1) * self.unit_height)
+        else:
+            y += int((self.rack.u_height - position + 1) * self.unit_height) - int(height * self.unit_height)
+
+        return x, y
+
+    def _draw_device(self, device, coords, size, color=None, image=None):
+        name = get_device_name(device)
+        description = get_device_description(device)
+        text_coords = (
+            coords[0] + size[0] / 2,
+            coords[1] + size[1] / 2
+        )
+        text_color = f'#{foreground_color(color)}' if color else '#000000'
+
+        # Create hyperlink element
+        link = Hyperlink(
+            href='{}{}'.format(
+                self.base_url,
+                reverse('dcim:device', kwargs={'pk': device.pk})
+            ),
+            target='_blank',
+        )
+        link.set_desc(description)
+        if color:
+            link.add(Rect(coords, size, style=f'fill: #{color}', class_='slot'))
+        else:
+            link.add(Rect(coords, size, class_='slot blocked'))
+        link.add(Text(name, insert=text_coords, fill=text_color))
+
+        # Embed device type image if provided
+        if self.include_images and image:
+            image = Image(
+                href='{}{}'.format(self.base_url, image.url),
+                insert=coords,
+                size=size,
+                class_='device-image'
+            )
+            image.fit(scale='slice')
+            link.add(image)
+            link.add(Text(name, insert=text_coords, stroke='black',
+                     stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
+            link.add(Text(name, insert=text_coords, fill='white', class_='device-image-label'))
+
+        self.drawing.add(link)
+
+    def draw_device_front(self, device, coords, size):
+        """
+        Draw the front (mounted) face of a device.
+        """
+        color = device.device_role.color
+        image = device.device_type.front_image
+        self._draw_device(device, coords, size, color=color, image=image)
+
+    def draw_device_rear(self, device, coords, size):
+        """
+        Draw the rear (opposite) face of a device.
+        """
+        image = device.device_type.rear_image
+        self._draw_device(device, coords, size, image=image)
+
+    def draw_border(self):
+        """
+        Draw a border around the collection of rack units.
+        """
+        border_width = RACK_ELEVATION_BORDER_WIDTH
+        border_offset = RACK_ELEVATION_BORDER_WIDTH / 2
+        frame = Rect(
+            insert=(self.legend_width + border_offset, border_offset),
+            size=(self.unit_width + border_width, self.rack.u_height * self.unit_height + border_width),
+            class_='rack'
+        )
+        self.drawing.add(frame)
+
+    def draw_legend(self):
+        """
+        Draw the rack unit labels along the lefthand side of the elevation.
+        """
+        for ru in range(0, self.rack.u_height):
+            start_y = ru * self.unit_height + RACK_ELEVATION_BORDER_WIDTH
+            position_coordinates = (self.legend_width / 2, start_y + self.unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH)
+            unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru
+            self.drawing.add(
+                Text(str(unit), position_coordinates, class_='unit')
+            )
+
+    def draw_margin(self):
+        """
+        Draw any rack reservations in the right-hand margin alongside the rack elevation.
+        """
+        for reservation in self.rack.reservations.all():
+            for segment in array_to_ranges(reservation.units):
+                u_height = 1 if len(segment) == 1 else segment[1] + 1 - segment[0]
+                coords = self._get_device_coords(segment[0], u_height)
+                coords = (coords[0] + self.unit_width + RACK_ELEVATION_BORDER_WIDTH * 2, coords[1])
+                size = (
+                    self.margin_width,
+                    u_height * self.unit_height
+                )
+                link = Hyperlink(
+                    href='{}{}'.format(self.base_url, reservation.get_absolute_url()),
+                    target='_blank'
+                )
+                link.set_desc(f'Reservation #{reservation.pk}: {reservation.description}')
+                link.add(
+                    Rect(coords, size, class_='reservation')
+                )
+                self.drawing.add(link)
+
+    def draw_background(self, face):
+        """
+        Draw the rack unit placeholders which form the "background" of the rack elevation.
+        """
+        x_offset = RACK_ELEVATION_BORDER_WIDTH + self.legend_width
+        url_string = '{}?{}&position={{}}'.format(
+            reverse('dcim:device_add'),
+            urlencode({
+                'site': self.rack.site.pk,
+                'location': self.rack.location.pk if self.rack.location else '',
+                'rack': self.rack.pk,
+                'face': face,
+            })
+        )
+
+        for ru in range(0, self.rack.u_height):
+            y_offset = RACK_ELEVATION_BORDER_WIDTH + ru * self.unit_height
+            text_coords = (
+                x_offset + self.unit_width / 2,
+                y_offset + self.unit_height / 2
+            )
+
+            link = Hyperlink(href=url_string.format(ru), target='_blank')
+            link.add(Rect((x_offset, y_offset), (self.unit_width, self.unit_height), class_='slot'))
+            link.add(Text('add device', insert=text_coords, class_='add-device'))
+
+            self.drawing.add(link)
+
+    def draw_face(self, face, opposite=False):
+        """
+        Draw any occupied rack units for the specified rack face.
+        """
+        for unit in self.rack.get_rack_units(face=face, expand_devices=False):
+
+            # Loop through all units in the elevation
+            device = unit['device']
+            height = unit.get('height', decimal.Decimal(1.0))
+
+            device_coords = self._get_device_coords(unit['id'], height)
+            device_size = (
+                self.unit_width,
+                int(self.unit_height * height)
+            )
+
+            # Draw the device
+            if device and device.pk in self.permitted_device_ids:
+                if device.face == face and not opposite:
+                    self.draw_device_front(device, device_coords, device_size)
+                else:
+                    self.draw_device_rear(device, device_coords, device_size)
+
+            elif device:
+                # Devices which the user does not have permission to view are rendered only as unavailable space
+                self.drawing.add(Rect(device_coords, device_size, class_='blocked'))
+
+    def render(self, face):
+        """
+        Return an SVG document representing a rack elevation.
+        """
+
+        # Initialize the drawing
+        self.drawing = self._setup_drawing()
+
+        # Draw the empty rack, legend, and margin
+        self.draw_legend()
+        self.draw_background(face)
+        self.draw_margin()
+
+        # Draw the rack face
+        self.draw_face(face)
+
+        # Draw the rack border last
+        self.draw_border()
+
+        return self.drawing

+ 4 - 4
netbox/dcim/tables/devices.py

@@ -520,10 +520,10 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
         model = Interface
         fields = (
             'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu',
-            'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency',
-            'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link',
-            'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'ip_addresses', 'fhrp_groups', 'untagged_vlan',
-            'tagged_vlans', 'created', 'last_updated',
+            'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel',
+            'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable',
+            'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'ip_addresses',
+            'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated',
         )
         default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
 

+ 4 - 3
netbox/dcim/tables/sites.py

@@ -126,6 +126,7 @@ class LocationTable(NetBoxTable):
     site = tables.Column(
         linkify=True
     )
+    status = columns.ChoiceFieldColumn()
     tenant = TenantColumn()
     rack_count = columns.LinkedCountColumn(
         viewname='dcim:rack_list',
@@ -150,7 +151,7 @@ class LocationTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
         model = Location
         fields = (
-            'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'contacts',
-            'tags', 'actions', 'created', 'last_updated',
+            'pk', 'id', 'name', 'site', 'status', 'tenant', 'rack_count', 'device_count', 'description', 'slug',
+            'contacts', 'tags', 'actions', 'created', 'last_updated',
         )
-        default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description')
+        default_columns = ('pk', 'name', 'site', 'status', 'tenant', 'rack_count', 'device_count', 'description')

+ 1 - 1
netbox/dcim/tables/template_code.py

@@ -385,7 +385,7 @@ MODULEBAY_BUTTONS = """
             <i class="mdi mdi-server-minus" aria-hidden="true" title="Remove module"></i>
         </a>
     {% else %}
-        <a href="{% url 'dcim:module_add' %}?device={{ record.device.pk }}&module_bay={{ record.pk }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-success btn-sm">
+        <a href="{% url 'dcim:module_add' %}?device={{ record.device.pk }}&module_bay={{ record.pk }}&manufacturer={{ object.device_type.manufacturer_id }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-success btn-sm">
             <i class="mdi mdi-server-plus" aria-hidden="true" title="Install module"></i>
         </a>
     {% endif %}

+ 14 - 9
netbox/dcim/tests/test_api.py

@@ -197,13 +197,13 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
         Site.objects.bulk_create(sites)
 
         parent_locations = (
-            Location.objects.create(site=sites[0], name='Parent Location 1', slug='parent-location-1'),
-            Location.objects.create(site=sites[1], name='Parent Location 2', slug='parent-location-2'),
+            Location.objects.create(site=sites[0], name='Parent Location 1', slug='parent-location-1', status=LocationStatusChoices.STATUS_ACTIVE),
+            Location.objects.create(site=sites[1], name='Parent Location 2', slug='parent-location-2', status=LocationStatusChoices.STATUS_ACTIVE),
         )
 
-        Location.objects.create(site=sites[0], name='Location 1', slug='location-1', parent=parent_locations[0])
-        Location.objects.create(site=sites[0], name='Location 2', slug='location-2', parent=parent_locations[0])
-        Location.objects.create(site=sites[0], name='Location 3', slug='location-3', parent=parent_locations[0])
+        Location.objects.create(site=sites[0], name='Location 1', slug='location-1', parent=parent_locations[0], status=LocationStatusChoices.STATUS_ACTIVE)
+        Location.objects.create(site=sites[0], name='Location 2', slug='location-2', parent=parent_locations[0], status=LocationStatusChoices.STATUS_ACTIVE)
+        Location.objects.create(site=sites[0], name='Location 3', slug='location-3', parent=parent_locations[0], status=LocationStatusChoices.STATUS_ACTIVE)
 
         cls.create_data = [
             {
@@ -211,18 +211,21 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
                 'slug': 'test-location-4',
                 'site': sites[1].pk,
                 'parent': parent_locations[1].pk,
+                'status': LocationStatusChoices.STATUS_PLANNED,
             },
             {
                 'name': 'Test Location 5',
                 'slug': 'test-location-5',
                 'site': sites[1].pk,
                 'parent': parent_locations[1].pk,
+                'status': LocationStatusChoices.STATUS_PLANNED,
             },
             {
                 'name': 'Test Location 6',
                 'slug': 'test-location-6',
                 'site': sites[1].pk,
                 'parent': parent_locations[1].pk,
+                'status': LocationStatusChoices.STATUS_PLANNED,
             },
         ]
 
@@ -327,15 +330,15 @@ class RackTest(APIViewTestCases.APIViewTestCase):
 
         # Retrieve all units
         response = self.client.get(url, **self.header)
-        self.assertEqual(response.data['count'], 42)
+        self.assertEqual(response.data['count'], 84)
 
         # Search for specific units
         response = self.client.get(f'{url}?q=3', **self.header)
-        self.assertEqual(response.data['count'], 13)
+        self.assertEqual(response.data['count'], 26)
         response = self.client.get(f'{url}?q=U3', **self.header)
-        self.assertEqual(response.data['count'], 11)
+        self.assertEqual(response.data['count'], 22)
         response = self.client.get(f'{url}?q=U10', **self.header)
-        self.assertEqual(response.data['count'], 1)
+        self.assertEqual(response.data['count'], 2)
 
     def test_get_rack_elevation_svg(self):
         """
@@ -1507,6 +1510,8 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
                 'speed': 1000000,
                 'duplex': 'full',
                 'vrf': vrfs[0].pk,
+                'poe_mode': InterfacePoEModeChoices.MODE_PD,
+                'poe_type': InterfacePoETypeChoices.TYPE_1_8023AF,
                 'tagged_vlans': [vlans[0].pk, vlans[1].pk],
                 'untagged_vlan': vlans[2].pk,
             },

+ 118 - 11
netbox/dcim/tests/test_filtersets.py

@@ -265,9 +265,9 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests):
             location.save()
 
         locations = (
-            Location(name='Location 1', slug='location-1', site=sites[0], parent=parent_locations[0], description='A'),
-            Location(name='Location 2', slug='location-2', site=sites[1], parent=parent_locations[1], description='B'),
-            Location(name='Location 3', slug='location-3', site=sites[2], parent=parent_locations[2], description='C'),
+            Location(name='Location 1', slug='location-1', site=sites[0], parent=parent_locations[0], status=LocationStatusChoices.STATUS_PLANNED, description='A'),
+            Location(name='Location 2', slug='location-2', site=sites[1], parent=parent_locations[1], status=LocationStatusChoices.STATUS_STAGING, description='B'),
+            Location(name='Location 3', slug='location-3', site=sites[2], parent=parent_locations[2], status=LocationStatusChoices.STATUS_DECOMMISSIONING, description='C'),
         )
         for location in locations:
             location.save()
@@ -280,6 +280,10 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'slug': ['location-1', 'location-2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_status(self):
+        params = {'status': [LocationStatusChoices.STATUS_PLANNED, LocationStatusChoices.STATUS_STAGING]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_description(self):
         params = {'description': ['A', 'B']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -2540,14 +2544,109 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
         Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2)
 
         interfaces = (
-            Interface(device=devices[0], module=modules[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First', vrf=vrfs[0], speed=1000000, duplex='half'),
-            Interface(device=devices[1], module=modules[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second', vrf=vrfs[1], speed=1000000, duplex='full'),
-            Interface(device=devices[2], module=modules[2], name='Interface 3', label='C', type=InterfaceTypeChoices.TYPE_1GE_FIXED, enabled=False, mgmt_only=False, mtu=300, mode=InterfaceModeChoices.MODE_TAGGED_ALL, mac_address='00-00-00-00-00-03', description='Third', vrf=vrfs[2], speed=100000, duplex='half'),
-            Interface(device=devices[3], name='Interface 4', label='D', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40, speed=100000, duplex='full'),
-            Interface(device=devices[3], name='Interface 5', label='E', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40),
-            Interface(device=devices[3], name='Interface 6', label='F', type=InterfaceTypeChoices.TYPE_OTHER, enabled=False, mgmt_only=False, tx_power=40),
-            Interface(device=devices[3], name='Interface 7', type=InterfaceTypeChoices.TYPE_80211AC, rf_role=WirelessRoleChoices.ROLE_AP, rf_channel=WirelessChannelChoices.CHANNEL_24G_1, rf_channel_frequency=2412, rf_channel_width=22),
-            Interface(device=devices[3], name='Interface 8', type=InterfaceTypeChoices.TYPE_80211AC, rf_role=WirelessRoleChoices.ROLE_STATION, rf_channel=WirelessChannelChoices.CHANNEL_5G_32, rf_channel_frequency=5160, rf_channel_width=20),
+            Interface(
+                device=devices[0],
+                module=modules[0],
+                name='Interface 1',
+                label='A',
+                type=InterfaceTypeChoices.TYPE_1GE_SFP,
+                enabled=True,
+                mgmt_only=True,
+                mtu=100,
+                mode=InterfaceModeChoices.MODE_ACCESS,
+                mac_address='00-00-00-00-00-01',
+                description='First',
+                vrf=vrfs[0],
+                speed=1000000,
+                duplex='half',
+                poe_mode=InterfacePoEModeChoices.MODE_PSE,
+                poe_type=InterfacePoETypeChoices.TYPE_1_8023AF
+            ),
+            Interface(
+                device=devices[1],
+                module=modules[1],
+                name='Interface 2',
+                label='B',
+                type=InterfaceTypeChoices.TYPE_1GE_GBIC,
+                enabled=True,
+                mgmt_only=True,
+                mtu=200,
+                mode=InterfaceModeChoices.MODE_TAGGED,
+                mac_address='00-00-00-00-00-02',
+                description='Second',
+                vrf=vrfs[1],
+                speed=1000000,
+                duplex='full',
+                poe_mode=InterfacePoEModeChoices.MODE_PD,
+                poe_type=InterfacePoETypeChoices.TYPE_1_8023AF
+            ),
+            Interface(
+                device=devices[2],
+                module=modules[2],
+                name='Interface 3',
+                label='C',
+                type=InterfaceTypeChoices.TYPE_1GE_FIXED,
+                enabled=False,
+                mgmt_only=False,
+                mtu=300,
+                mode=InterfaceModeChoices.MODE_TAGGED_ALL,
+                mac_address='00-00-00-00-00-03',
+                description='Third',
+                vrf=vrfs[2],
+                speed=100000,
+                duplex='half',
+                poe_mode=InterfacePoEModeChoices.MODE_PSE,
+                poe_type=InterfacePoETypeChoices.TYPE_2_8023AT
+            ),
+            Interface(
+                device=devices[3],
+                name='Interface 4',
+                label='D',
+                type=InterfaceTypeChoices.TYPE_OTHER,
+                enabled=True,
+                mgmt_only=True,
+                tx_power=40,
+                speed=100000,
+                duplex='full',
+                poe_mode=InterfacePoEModeChoices.MODE_PD,
+                poe_type=InterfacePoETypeChoices.TYPE_2_8023AT
+            ),
+            Interface(
+                device=devices[3],
+                name='Interface 5',
+                label='E',
+                type=InterfaceTypeChoices.TYPE_OTHER,
+                enabled=True,
+                mgmt_only=True,
+                tx_power=40
+            ),
+            Interface(
+                device=devices[3],
+                name='Interface 6',
+                label='F',
+                type=InterfaceTypeChoices.TYPE_OTHER,
+                enabled=False,
+                mgmt_only=False,
+                tx_power=40
+            ),
+            Interface(
+                device=devices[3],
+                name='Interface 7',
+                type=InterfaceTypeChoices.TYPE_80211AC,
+                rf_role=WirelessRoleChoices.ROLE_AP,
+                rf_channel=WirelessChannelChoices.CHANNEL_24G_1,
+                rf_channel_frequency=2412,
+                rf_channel_width=22
+            ),
+            Interface(
+                device=devices[3],
+                name='Interface 8',
+                type=InterfaceTypeChoices.TYPE_80211AC,
+                rf_role=WirelessRoleChoices.ROLE_STATION,
+                rf_channel=WirelessChannelChoices.CHANNEL_5G_32,
+                rf_channel_frequency=5160,
+                rf_channel_width=20
+            ),
         )
         Interface.objects.bulk_create(interfaces)
 
@@ -2594,6 +2693,14 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'mgmt_only': 'false'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
+    def test_poe_mode(self):
+        params = {'poe_mode': [InterfacePoEModeChoices.MODE_PD, InterfacePoEModeChoices.MODE_PSE]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+    def test_poe_type(self):
+        params = {'poe_type': [InterfacePoETypeChoices.TYPE_1_8023AF, InterfacePoETypeChoices.TYPE_2_8023AT]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
     def test_mode(self):
         params = {'mode': InterfaceModeChoices.MODE_ACCESS}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)

+ 103 - 111
netbox/dcim/tests/test_models.py

@@ -5,6 +5,7 @@ from circuits.models import *
 from dcim.choices import *
 from dcim.models import *
 from tenancy.models import Tenant
+from utilities.utils import drange
 
 
 class LocationTestCase(TestCase):
@@ -74,148 +75,142 @@ class RackTestCase(TestCase):
 
     def setUp(self):
 
-        self.site1 = Site.objects.create(
-            name='TestSite1',
-            slug='test-site-1'
+        sites = (
+            Site(name='Site 1', slug='site-1'),
+            Site(name='Site 2', slug='site-2'),
         )
-        self.site2 = Site.objects.create(
-            name='TestSite2',
-            slug='test-site-2'
-        )
-        self.location1 = Location.objects.create(
-            name='TestGroup1',
-            slug='test-group-1',
-            site=self.site1
-        )
-        self.location2 = Location.objects.create(
-            name='TestGroup2',
-            slug='test-group-2',
-            site=self.site2
+        Site.objects.bulk_create(sites)
+
+        locations = (
+            Location(name='Location 1', slug='location-1', site=sites[0]),
+            Location(name='Location 2', slug='location-2', site=sites[1]),
         )
-        self.rack = Rack.objects.create(
-            name='TestRack1',
+        for location in locations:
+            location.save()
+
+        Rack.objects.create(
+            name='Rack 1',
             facility_id='A101',
-            site=self.site1,
-            location=self.location1,
+            site=sites[0],
+            location=locations[0],
             u_height=42
         )
-        self.manufacturer = Manufacturer.objects.create(
-            name='Acme',
-            slug='acme'
-        )
-
-        self.device_type = {
-            'ff2048': DeviceType.objects.create(
-                manufacturer=self.manufacturer,
-                model='FrameForwarder 2048',
-                slug='ff2048'
-            ),
-            'cc5000': DeviceType.objects.create(
-                manufacturer=self.manufacturer,
-                model='CurrentCatapult 5000',
-                slug='cc5000',
-                u_height=0
-            ),
-        }
-        self.role = {
-            'Server': DeviceRole.objects.create(
-                name='Server',
-                slug='server',
-            ),
-            'Switch': DeviceRole.objects.create(
-                name='Switch',
-                slug='switch',
-            ),
-            'Console Server': DeviceRole.objects.create(
-                name='Console Server',
-                slug='console-server',
-            ),
-            'PDU': DeviceRole.objects.create(
-                name='PDU',
-                slug='pdu',
-            ),
 
-        }
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        device_types = (
+            DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', u_height=1),
+            DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', u_height=0),
+            DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3', u_height=0.5),
+        )
+        DeviceType.objects.bulk_create(device_types)
 
-    def test_rack_device_outside_height(self):
+        DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
 
-        rack1 = Rack(
-            name='TestRack2',
-            facility_id='A102',
-            site=self.site1,
-            u_height=42
-        )
-        rack1.save()
+    def test_rack_device_outside_height(self):
+        site = Site.objects.first()
+        rack = Rack.objects.first()
 
         device1 = Device(
-            name='TestSwitch1',
-            device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'),
-            device_role=DeviceRole.objects.get(slug='switch'),
-            site=self.site1,
-            rack=rack1,
+            name='Device 1',
+            device_type=DeviceType.objects.first(),
+            device_role=DeviceRole.objects.first(),
+            site=site,
+            rack=rack,
             position=43,
             face=DeviceFaceChoices.FACE_FRONT,
         )
         device1.save()
 
         with self.assertRaises(ValidationError):
-            rack1.clean()
+            rack.clean()
 
     def test_location_site(self):
+        site1 = Site.objects.get(name='Site 1')
+        location2 = Location.objects.get(name='Location 2')
 
-        rack_invalid_location = Rack(
-            name='TestRack2',
-            facility_id='A102',
-            site=self.site1,
-            u_height=42,
-            location=self.location2
+        rack2 = Rack(
+            name='Rack 2',
+            site=site1,
+            location=location2,
+            u_height=42
         )
-        rack_invalid_location.save()
+        rack2.save()
 
         with self.assertRaises(ValidationError):
-            rack_invalid_location.clean()
+            rack2.clean()
 
     def test_mount_single_device(self):
+        site = Site.objects.first()
+        rack = Rack.objects.first()
 
         device1 = Device(
             name='TestSwitch1',
-            device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'),
-            device_role=DeviceRole.objects.get(slug='switch'),
-            site=self.site1,
-            rack=self.rack,
-            position=10,
+            device_type=DeviceType.objects.first(),
+            device_role=DeviceRole.objects.first(),
+            site=site,
+            rack=rack,
+            position=10.0,
             face=DeviceFaceChoices.FACE_REAR,
         )
         device1.save()
 
         # Validate rack height
-        self.assertEqual(list(self.rack.units), list(reversed(range(1, 43))))
+        self.assertEqual(list(rack.units), list(drange(42.5, 0.5, -0.5)))
 
         # Validate inventory (front face)
-        rack1_inventory_front = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_FRONT)
-        self.assertEqual(rack1_inventory_front[-10]['device'], device1)
-        del(rack1_inventory_front[-10])
-        for u in rack1_inventory_front:
+        rack1_inventory_front = {
+            u['id']: u for u in rack.get_rack_units(face=DeviceFaceChoices.FACE_FRONT)
+        }
+        self.assertEqual(rack1_inventory_front[10.0]['device'], device1)
+        self.assertEqual(rack1_inventory_front[10.5]['device'], device1)
+        del(rack1_inventory_front[10.0])
+        del(rack1_inventory_front[10.5])
+        for u in rack1_inventory_front.values():
             self.assertIsNone(u['device'])
 
         # Validate inventory (rear face)
-        rack1_inventory_rear = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_REAR)
-        self.assertEqual(rack1_inventory_rear[-10]['device'], device1)
-        del(rack1_inventory_rear[-10])
-        for u in rack1_inventory_rear:
+        rack1_inventory_rear = {
+            u['id']: u for u in rack.get_rack_units(face=DeviceFaceChoices.FACE_REAR)
+        }
+        self.assertEqual(rack1_inventory_rear[10.0]['device'], device1)
+        self.assertEqual(rack1_inventory_rear[10.5]['device'], device1)
+        del(rack1_inventory_rear[10.0])
+        del(rack1_inventory_rear[10.5])
+        for u in rack1_inventory_rear.values():
             self.assertIsNone(u['device'])
 
     def test_mount_zero_ru(self):
-        pdu = Device.objects.create(
-            name='TestPDU',
-            device_role=self.role.get('PDU'),
-            device_type=self.device_type.get('cc5000'),
-            site=self.site1,
-            rack=self.rack,
-            position=None,
-            face='',
-        )
-        self.assertTrue(pdu)
+        """
+        Check that a 0RU device can be mounted in a rack with no face/position.
+        """
+        site = Site.objects.first()
+        rack = Rack.objects.first()
+
+        Device(
+            name='Device 1',
+            device_role=DeviceRole.objects.first(),
+            device_type=DeviceType.objects.first(),
+            site=site,
+            rack=rack
+        ).save()
+
+    def test_mount_half_u_devices(self):
+        """
+        Check that two 0.5U devices can be mounted in the same rack unit.
+        """
+        rack = Rack.objects.first()
+        attrs = {
+            'device_type': DeviceType.objects.get(u_height=0.5),
+            'device_role': DeviceRole.objects.first(),
+            'site': Site.objects.first(),
+            'rack': rack,
+            'face': DeviceFaceChoices.FACE_FRONT,
+        }
+
+        Device(name='Device 1', position=1, **attrs).save()
+        Device(name='Device 2', position=1.5, **attrs).save()
+
+        self.assertEqual(len(rack.get_available_units()), rack.u_height * 2 - 3)
 
     def test_change_rack_site(self):
         """
@@ -224,19 +219,16 @@ class RackTestCase(TestCase):
         site_a = Site.objects.create(name='Site A', slug='site-a')
         site_b = Site.objects.create(name='Site B', slug='site-b')
 
-        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
-        device_type = DeviceType.objects.create(
-            manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
-        )
-        device_role = DeviceRole.objects.create(
-            name='Device Role 1', slug='device-role-1', color='ff0000'
-        )
-
         # Create Rack1 in Site A
         rack1 = Rack.objects.create(site=site_a, name='Rack 1')
 
         # Create Device1 in Rack1
-        device1 = Device.objects.create(site=site_a, rack=rack1, device_type=device_type, device_role=device_role)
+        device1 = Device.objects.create(
+            site=site_a,
+            rack=rack1,
+            device_type=DeviceType.objects.first(),
+            device_role=DeviceRole.objects.first()
+        )
 
         # Move Rack1 to Site B
         rack1.site = site_b

+ 18 - 11
netbox/dcim/tests/test_views.py

@@ -175,9 +175,9 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
         tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1')
 
         locations = (
-            Location(name='Location 1', slug='location-1', site=site, tenant=tenant),
-            Location(name='Location 2', slug='location-2', site=site, tenant=tenant),
-            Location(name='Location 3', slug='location-3', site=site, tenant=tenant),
+            Location(name='Location 1', slug='location-1', site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant),
+            Location(name='Location 2', slug='location-2', site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant),
+            Location(name='Location 3', slug='location-3', site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant),
         )
         for location in locations:
             location.save()
@@ -188,16 +188,17 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             'name': 'Location X',
             'slug': 'location-x',
             'site': site.pk,
+            'status': LocationStatusChoices.STATUS_PLANNED,
             'tenant': tenant.pk,
             'description': 'A new location',
             'tags': [t.pk for t in tags],
         }
 
         cls.csv_data = (
-            "site,tenant,name,slug,description",
-            "Site 1,Tenant 1,Location 4,location-4,Fourth location",
-            "Site 1,Tenant 1,Location 5,location-5,Fifth location",
-            "Site 1,Tenant 1,Location 6,location-6,Sixth location",
+            "site,tenant,name,slug,status,description",
+            "Site 1,Tenant 1,Location 4,location-4,planned,Fourth location",
+            "Site 1,Tenant 1,Location 5,location-5,planned,Fifth location",
+            "Site 1,Tenant 1,Location 6,location-6,planned,Sixth location",
         )
 
         cls.bulk_edit_data = {
@@ -2204,6 +2205,8 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
             'description': 'A front port',
             'mode': InterfaceModeChoices.MODE_TAGGED,
             'tx_power': 10,
+            'poe_mode': InterfacePoEModeChoices.MODE_PSE,
+            'poe_type': InterfacePoETypeChoices.TYPE_1_8023AF,
             'untagged_vlan': vlans[0].pk,
             'tagged_vlans': [v.pk for v in vlans[1:4]],
             'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk],
@@ -2225,6 +2228,8 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
             'duplex': 'half',
             'mgmt_only': True,
             'description': 'A front port',
+            'poe_mode': InterfacePoEModeChoices.MODE_PSE,
+            'poe_type': InterfacePoETypeChoices.TYPE_1_8023AF,
             'mode': InterfaceModeChoices.MODE_TAGGED,
             'untagged_vlan': vlans[0].pk,
             'tagged_vlans': [v.pk for v in vlans[1:4]],
@@ -2244,6 +2249,8 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
             'duplex': 'full',
             'mgmt_only': True,
             'description': 'New description',
+            'poe_mode': InterfacePoEModeChoices.MODE_PD,
+            'poe_type': InterfacePoETypeChoices.TYPE_2_8023AT,
             'mode': InterfaceModeChoices.MODE_TAGGED,
             'tx_power': 10,
             'untagged_vlan': vlans[0].pk,
@@ -2252,10 +2259,10 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
         }
 
         cls.csv_data = (
-            f"device,name,type,vrf.pk",
-            f"Device 1,Interface 4,1000base-t,{vrfs[0].pk}",
-            f"Device 1,Interface 5,1000base-t,{vrfs[0].pk}",
-            f"Device 1,Interface 6,1000base-t,{vrfs[0].pk}",
+            f"device,name,type,vrf.pk,poe_mode,poe_type",
+            f"Device 1,Interface 4,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af",
+            f"Device 1,Interface 5,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af",
+            f"Device 1,Interface 6,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af",
         )
 
     @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])

+ 2 - 2
netbox/dcim/views.py

@@ -510,8 +510,8 @@ class RackRoleView(generic.ObjectView):
     queryset = RackRole.objects.all()
 
     def get_extra_context(self, request, instance):
-        racks = Rack.objects.restrict(request.user, 'view').filter(
-            role=instance
+        racks = Rack.objects.restrict(request.user, 'view').filter(role=instance).annotate(
+            device_count=count_related(Device, 'rack')
         )
 
         racks_table = tables.RackTable(racks, user=request.user, exclude=(

+ 15 - 8
netbox/extras/api/serializers.py

@@ -5,10 +5,10 @@ from drf_yasg.utils import swagger_serializer_method
 from rest_framework import serializers
 
 from dcim.api.nested_serializers import (
-    NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedPlatformSerializer, NestedRegionSerializer,
-    NestedSiteSerializer, NestedSiteGroupSerializer,
+    NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer,
+    NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
 )
-from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
+from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
 from extras.choices import *
 from extras.models import *
 from extras.utils import FeatureQuery
@@ -84,13 +84,14 @@ class CustomFieldSerializer(ValidatedModelSerializer):
     )
     filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False)
     data_type = serializers.SerializerMethodField()
+    ui_visibility = ChoiceField(choices=CustomFieldVisibilityChoices, required=False)
 
     class Meta:
         model = CustomField
         fields = [
-            'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'description',
-            'required', 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum',
-            'validation_regex', 'choices', 'created', 'last_updated',
+            'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name',
+            'description', 'required', 'filter_logic', 'ui_visibility', 'default', 'weight', 'validation_minimum',
+            'validation_maximum', 'validation_regex', 'choices', 'created', 'last_updated',
         ]
 
     def get_data_type(self, obj):
@@ -271,6 +272,12 @@ class ConfigContextSerializer(ValidatedModelSerializer):
         required=False,
         many=True
     )
+    locations = SerializedPKRelatedField(
+        queryset=Location.objects.all(),
+        serializer=NestedLocationSerializer,
+        required=False,
+        many=True
+    )
     device_types = SerializedPKRelatedField(
         queryset=DeviceType.objects.all(),
         serializer=NestedDeviceTypeSerializer,
@@ -330,8 +337,8 @@ class ConfigContextSerializer(ValidatedModelSerializer):
         model = ConfigContext
         fields = [
             'id', 'url', 'display', 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites',
-            'device_types', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
-            'tenants', 'tags', 'data', 'created', 'last_updated',
+            'locations', 'device_types', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters',
+            'tenant_groups', 'tenants', 'tags', 'data', 'created', 'last_updated',
         ]
 
 

+ 1 - 1
netbox/extras/api/views.py

@@ -138,7 +138,7 @@ class JournalEntryViewSet(NetBoxModelViewSet):
 
 class ConfigContextViewSet(NetBoxModelViewSet):
     queryset = ConfigContext.objects.prefetch_related(
-        'regions', 'site_groups', 'sites', 'roles', 'platforms', 'tenant_groups', 'tenants',
+        'regions', 'site_groups', 'sites', 'locations', 'roles', 'platforms', 'tenant_groups', 'tenants',
     )
     serializer_class = serializers.ConfigContextSerializer
     filterset_class = filtersets.ConfigContextFilterSet

+ 13 - 0
netbox/extras/choices.py

@@ -47,6 +47,19 @@ class CustomFieldFilterLogicChoices(ChoiceSet):
     )
 
 
+class CustomFieldVisibilityChoices(ChoiceSet):
+
+    VISIBILITY_READ_WRITE = 'read-write'
+    VISIBILITY_READ_ONLY = 'read-only'
+    VISIBILITY_HIDDEN = 'hidden'
+
+    CHOICES = (
+        (VISIBILITY_READ_WRITE, 'Read/Write'),
+        (VISIBILITY_READ_ONLY, 'Read-only'),
+        (VISIBILITY_HIDDEN, 'Hidden'),
+    )
+
+
 #
 # CustomLinks
 #

+ 17 - 2
netbox/extras/filtersets.py

@@ -3,7 +3,7 @@ from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 from django.db.models import Q
 
-from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
+from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
 from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
 from tenancy.models import Tenant, TenantGroup
 from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
@@ -62,7 +62,10 @@ class CustomFieldFilterSet(BaseFilterSet):
 
     class Meta:
         model = CustomField
-        fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight', 'description']
+        fields = [
+            'id', 'content_types', 'name', 'group_name', 'required', 'filter_logic', 'ui_visibility', 'weight',
+            'description',
+        ]
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -70,6 +73,7 @@ class CustomFieldFilterSet(BaseFilterSet):
         return queryset.filter(
             Q(name__icontains=value) |
             Q(label__icontains=value) |
+            Q(group_name__icontains=value) |
             Q(description__icontains=value)
         )
 
@@ -251,6 +255,17 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
         to_field_name='slug',
         label='Site (slug)',
     )
+    location_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='locations',
+        queryset=Location.objects.all(),
+        label='Location',
+    )
+    location = django_filters.ModelMultipleChoiceFilter(
+        field_name='locations__slug',
+        queryset=Location.objects.all(),
+        to_field_name='slug',
+        label='Location (slug)',
+    )
     device_type_id = django_filters.ModelMultipleChoiceFilter(
         field_name='device_types',
         queryset=DeviceType.objects.all(),

+ 11 - 1
netbox/extras/forms/bulk_edit.py

@@ -24,6 +24,9 @@ class CustomFieldBulkEditForm(BulkEditForm):
         queryset=CustomField.objects.all(),
         widget=forms.MultipleHiddenInput
     )
+    group_name = forms.CharField(
+        required=False
+    )
     description = forms.CharField(
         required=False
     )
@@ -34,8 +37,15 @@ class CustomFieldBulkEditForm(BulkEditForm):
     weight = forms.IntegerField(
         required=False
     )
+    ui_visibility = forms.ChoiceField(
+        label="UI visibility",
+        choices=add_blank_choice(CustomFieldVisibilityChoices),
+        required=False,
+        initial='',
+        widget=StaticSelect()
+    )
 
-    nullable_fields = ('description',)
+    nullable_fields = ('group_name', 'description',)
 
 
 class CustomLinkBulkEditForm(BulkEditForm):

+ 9 - 2
netbox/extras/forms/bulk_import.py

@@ -27,6 +27,12 @@ class CustomFieldCSVForm(CSVModelForm):
         choices=CustomFieldTypeChoices,
         help_text='Field data type (e.g. text, integer, etc.)'
     )
+    object_type = CSVContentTypeField(
+        queryset=ContentType.objects.all(),
+        limit_choices_to=FeatureQuery('custom_fields'),
+        required=False,
+        help_text="Object type (for object or multi-object fields)"
+    )
     choices = SimpleArrayField(
         base_field=forms.CharField(),
         required=False,
@@ -36,8 +42,9 @@ class CustomFieldCSVForm(CSVModelForm):
     class Meta:
         model = CustomField
         fields = (
-            'name', 'label', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', 'default',
-            'choices', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
+            'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description', 'weight',
+            'filter_logic', 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum',
+            'validation_regex', 'ui_visibility',
         )
 
 

+ 11 - 0
netbox/extras/forms/customfields.py

@@ -1,6 +1,7 @@
 from django.contrib.contenttypes.models import ContentType
 
 from extras.models import *
+from extras.choices import CustomFieldVisibilityChoices
 
 __all__ = (
     'CustomFieldsMixin',
@@ -42,8 +43,18 @@ class CustomFieldsMixin:
         Append form fields for all CustomFields assigned to this object type.
         """
         for customfield in self._get_custom_fields(self._get_content_type()):
+            if customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN:
+                continue
+
             field_name = f'cf_{customfield.name}'
             self.fields[field_name] = self._get_form_field(customfield)
 
+            if customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY:
+                self.fields[field_name].disabled = True
+                if self.fields[field_name].help_text:
+                    self.fields[field_name].help_text += '<br />'
+                self.fields[field_name].help_text += '<i class="mdi mdi-alert-circle-outline"></i> ' \
+                                                     'Field is set to read-only.'
+
             # Annotate the field in the list of CustomField form fields
             self.custom_fields[field_name] = customfield

+ 17 - 3
netbox/extras/forms/filtersets.py

@@ -3,7 +3,7 @@ from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext as _
 
-from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
+from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
 from extras.choices import *
 from extras.models import *
 from extras.utils import FeatureQuery
@@ -32,7 +32,7 @@ __all__ = (
 class CustomFieldFilterForm(FilterForm):
     fieldsets = (
         (None, ('q',)),
-        ('Attributes', ('type', 'content_types', 'weight', 'required')),
+        ('Attributes', ('content_types', 'type', 'group_name', 'weight', 'required', 'ui_visibility')),
     )
     content_types = ContentTypeMultipleChoiceField(
         queryset=ContentType.objects.all(),
@@ -44,6 +44,9 @@ class CustomFieldFilterForm(FilterForm):
         required=False,
         label=_('Field type')
     )
+    group_name = forms.CharField(
+        required=False
+    )
     weight = forms.IntegerField(
         required=False
     )
@@ -53,6 +56,12 @@ class CustomFieldFilterForm(FilterForm):
             choices=BOOLEAN_WITH_BLANK_CHOICES
         )
     )
+    ui_visibility = forms.ChoiceField(
+        choices=add_blank_choice(CustomFieldVisibilityChoices),
+        required=False,
+        label=_('UI visibility'),
+        widget=StaticSelect()
+    )
 
 
 class CustomLinkFilterForm(FilterForm):
@@ -161,7 +170,7 @@ class TagFilterForm(FilterForm):
 class ConfigContextFilterForm(FilterForm):
     fieldsets = (
         (None, ('q', 'tag_id')),
-        ('Location', ('region_id', 'site_group_id', 'site_id')),
+        ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
         ('Device', ('device_type_id', 'platform_id', 'role_id')),
         ('Cluster', ('cluster_type_id', 'cluster_group_id', 'cluster_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id'))
@@ -181,6 +190,11 @@ class ConfigContextFilterForm(FilterForm):
         required=False,
         label=_('Sites')
     )
+    location_id = DynamicModelMultipleChoiceField(
+        queryset=Location.objects.all(),
+        required=False,
+        label=_('Locations')
+    )
     device_type_id = DynamicModelMultipleChoiceField(
         queryset=DeviceType.objects.all(),
         required=False,

+ 21 - 7
netbox/extras/forms/models.py

@@ -1,7 +1,7 @@
 from django import forms
 from django.contrib.contenttypes.models import ContentType
 
-from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
+from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
 from extras.choices import *
 from extras.models import *
 from extras.utils import FeatureQuery
@@ -40,8 +40,10 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
     )
 
     fieldsets = (
-        ('Custom Field', ('content_types', 'name', 'label', 'type', 'object_type', 'weight', 'required', 'description')),
-        ('Behavior', ('filter_logic',)),
+        ('Custom Field', (
+            'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'weight', 'required', 'description',
+        )),
+        ('Behavior', ('filter_logic', 'ui_visibility')),
         ('Values', ('default', 'choices')),
         ('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
     )
@@ -56,6 +58,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
         widgets = {
             'type': StaticSelect(),
             'filter_logic': StaticSelect(),
+            'ui_visibility': StaticSelect(),
         }
 
 
@@ -163,6 +166,10 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
         queryset=Site.objects.all(),
         required=False
     )
+    locations = DynamicModelMultipleChoiceField(
+        queryset=Location.objects.all(),
+        required=False
+    )
     device_types = DynamicModelMultipleChoiceField(
         queryset=DeviceType.objects.all(),
         required=False
@@ -199,15 +206,22 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
         queryset=Tag.objects.all(),
         required=False
     )
-    data = JSONField(
-        label=''
+    data = JSONField()
+
+    fieldsets = (
+        ('Config Context', ('name', 'weight', 'description', 'data', 'is_active')),
+        ('Assignment', (
+            'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types',
+            'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags',
+        )),
     )
 
     class Meta:
         model = ConfigContext
         fields = (
-            'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', 'roles', 'device_types',
-            'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
+            'name', 'weight', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites', 'locations',
+            'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
+            'tenants', 'tags',
         )
 
 

+ 3 - 2
netbox/extras/management/commands/runscript.py

@@ -14,6 +14,7 @@ from extras.choices import JobResultStatusChoices
 from extras.context_managers import change_logging
 from extras.models import JobResult
 from extras.scripts import get_script
+from extras.signals import clear_webhooks
 from utilities.exceptions import AbortTransaction
 from utilities.utils import NetBoxFakeRequest
 
@@ -49,7 +50,7 @@ class Command(BaseCommand):
 
             except AbortTransaction:
                 script.log_info("Database changes have been reverted automatically.")
-
+                clear_webhooks.send(request)
             except Exception as e:
                 stacktrace = traceback.format_exc()
                 script.log_failure(
@@ -58,7 +59,7 @@ class Command(BaseCommand):
                 script.log_info("Database changes have been reverted due to error.")
                 logger.error(f"Exception raised during script execution: {e}")
                 job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
-
+                clear_webhooks.send(request)
             finally:
                 job_result.data = ScriptOutputSerializer(script).data
                 job_result.save()

+ 22 - 0
netbox/extras/migrations/0074_customfield_group_name.py

@@ -0,0 +1,22 @@
+# Generated by Django 4.0.4 on 2022-04-15 17:13
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0073_journalentry_tags_custom_fields'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='customfield',
+            options={'ordering': ['group_name', 'weight', 'name']},
+        ),
+        migrations.AddField(
+            model_name='customfield',
+            name='group_name',
+            field=models.CharField(blank=True, max_length=50),
+        ),
+    ]

+ 18 - 0
netbox/extras/migrations/0075_customfield_ui_visibility.py

@@ -0,0 +1,18 @@
+# Generated by Django 4.0.4 on 2022-05-23 20:23
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0074_customfield_group_name'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='customfield',
+            name='ui_visibility',
+            field=models.CharField(default='read-write', max_length=50),
+        ),
+    ]

+ 19 - 0
netbox/extras/migrations/0076_configcontext_locations.py

@@ -0,0 +1,19 @@
+# Generated by Django 4.0.5 on 2022-06-22 19:13
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0156_location_status'),
+        ('extras', '0075_customfield_ui_visibility'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='configcontext',
+            name='locations',
+            field=models.ManyToManyField(blank=True, related_name='+', to='dcim.location'),
+        ),
+    ]

+ 7 - 5
netbox/extras/models/configcontexts.py

@@ -1,5 +1,3 @@
-from collections import OrderedDict
-
 from django.core.validators import ValidationError
 from django.db import models
 from django.urls import reverse
@@ -55,6 +53,11 @@ class ConfigContext(WebhooksMixin, ChangeLoggedModel):
         related_name='+',
         blank=True
     )
+    locations = models.ManyToManyField(
+        to='dcim.Location',
+        related_name='+',
+        blank=True
+    )
     device_types = models.ManyToManyField(
         to='dcim.DeviceType',
         related_name='+',
@@ -138,11 +141,10 @@ class ConfigContextModel(models.Model):
 
     def get_config_context(self):
         """
+        Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs.
         Return the rendered configuration context for a device or VM.
         """
-
-        # Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs
-        data = OrderedDict()
+        data = {}
 
         if not hasattr(self, 'config_context_data'):
             # The annotation is not available, so we fall back to manually querying for the config context objects

+ 13 - 1
netbox/extras/models/customfields.py

@@ -79,6 +79,11 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
         help_text='Name of the field as displayed to users (if not provided, '
                   'the field\'s name will be used)'
     )
+    group_name = models.CharField(
+        max_length=50,
+        blank=True,
+        help_text="Custom fields within the same group will be displayed together"
+    )
     description = models.CharField(
         max_length=200,
         blank=True
@@ -131,10 +136,17 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
         null=True,
         help_text='Comma-separated list of available choices (for selection fields)'
     )
+    ui_visibility = models.CharField(
+        max_length=50,
+        choices=CustomFieldVisibilityChoices,
+        default=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE,
+        verbose_name='UI visibility',
+        help_text='Specifies the visibility of custom field in the UI'
+    )
     objects = CustomFieldManager()
 
     class Meta:
-        ordering = ['weight', 'name']
+        ordering = ['group_name', 'weight', 'name']
 
     def __str__(self):
         return self.label or self.name.replace('_', ' ').capitalize()

+ 4 - 1
netbox/extras/querysets.py

@@ -19,8 +19,9 @@ class ConfigContextQuerySet(RestrictedQuerySet):
         # `device_role` for Device; `role` for VirtualMachine
         role = getattr(obj, 'device_role', None) or obj.role
 
-        # Device type assignment is relevant only for Devices
+        # Device type and location assignment is relevant only for Devices
         device_type = getattr(obj, 'device_type', None)
+        location = getattr(obj, 'location', None)
 
         # Get assigned cluster, group, and type (if any)
         cluster = getattr(obj, 'cluster', None)
@@ -42,6 +43,7 @@ class ConfigContextQuerySet(RestrictedQuerySet):
             Q(regions__in=regions) | Q(regions=None),
             Q(site_groups__in=sitegroups) | Q(site_groups=None),
             Q(sites=obj.site) | Q(sites=None),
+            Q(locations=location) | Q(locations=None),
             Q(device_types=device_type) | Q(device_types=None),
             Q(roles=role) | Q(roles=None),
             Q(platforms=obj.platform) | Q(platforms=None),
@@ -114,6 +116,7 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
         )
 
         if self.model._meta.model_name == 'device':
+            base_query.add((Q(locations=OuterRef('location')) | Q(locations=None)), Q.AND)
             base_query.add((Q(device_types=OuterRef('device_type')) | Q(device_types=None)), Q.AND)
             base_query.add((Q(roles=OuterRef('device_role')) | Q(roles=None)), Q.AND)
             base_query.add((Q(sites=OuterRef('site')) | Q(sites=None)), Q.AND)

+ 13 - 5
netbox/extras/scripts.py

@@ -17,6 +17,7 @@ from django.utils.functional import classproperty
 
 from extras.api.serializers import ScriptOutputSerializer
 from extras.choices import JobResultStatusChoices, LogLevelChoices
+from extras.signals import clear_webhooks
 from ipam.formfields import IPAddressFormField, IPNetworkFormField
 from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
 from utilities.exceptions import AbortTransaction
@@ -306,9 +307,16 @@ class BaseScript:
     @classmethod
     def _get_vars(cls):
         vars = {}
-        for name, attr in cls.__dict__.items():
-            if name not in vars and issubclass(attr.__class__, ScriptVariable):
-                vars[name] = attr
+
+        # Iterate all base classes looking for ScriptVariables
+        for base_class in inspect.getmro(cls):
+            # When object is reached there's no reason to continue
+            if base_class is object:
+                break
+
+            for name, attr in base_class.__dict__.items():
+                if name not in vars and issubclass(attr.__class__, ScriptVariable):
+                    vars[name] = attr
 
         # Order variables according to field_order
         field_order = getattr(cls.Meta, 'field_order', None)
@@ -458,7 +466,7 @@ def run_script(data, request, commit=True, *args, **kwargs):
 
         except AbortTransaction:
             script.log_info("Database changes have been reverted automatically.")
-
+            clear_webhooks.send(request)
         except Exception as e:
             stacktrace = traceback.format_exc()
             script.log_failure(
@@ -467,7 +475,7 @@ def run_script(data, request, commit=True, *args, **kwargs):
             script.log_info("Database changes have been reverted due to error.")
             logger.error(f"Exception raised during script execution: {e}")
             job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
-
+            clear_webhooks.send(request)
         finally:
             job_result.data = ScriptOutputSerializer(script).data
             job_result.save()

+ 7 - 5
netbox/extras/tables/tables.py

@@ -28,14 +28,15 @@ class CustomFieldTable(NetBoxTable):
     )
     content_types = columns.ContentTypesColumn()
     required = columns.BooleanColumn()
+    ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility")
 
     class Meta(NetBoxTable.Meta):
         model = CustomField
         fields = (
-            'pk', 'id', 'name', 'content_types', 'label', 'type', 'required', 'weight', 'default',
-            'description', 'filter_logic', 'choices', 'created', 'last_updated',
+            'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'weight', 'default',
+            'description', 'filter_logic', 'ui_visibility', 'choices', 'created', 'last_updated',
         )
-        default_columns = ('pk', 'name', 'content_types', 'label', 'type', 'required', 'description')
+        default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description')
 
 
 #
@@ -166,8 +167,9 @@ class ConfigContextTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
         model = ConfigContext
         fields = (
-            'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'roles', 'platforms',
-            'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'created', 'last_updated',
+            'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'locations', 'roles',
+            'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'created',
+            'last_updated',
         )
         default_columns = ('pk', 'name', 'weight', 'is_active', 'description')
 

+ 23 - 7
netbox/extras/tests/test_filtersets.py

@@ -6,7 +6,7 @@ from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase
 
 from circuits.models import Provider
-from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup
+from dcim.models import DeviceRole, DeviceType, Location, Manufacturer, Platform, Rack, Region, Site, SiteGroup
 from extras.choices import JournalEntryKindChoices, ObjectChangeActionChoices
 from extras.filtersets import *
 from extras.models import *
@@ -368,9 +368,9 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
     def setUpTestData(cls):
 
         regions = (
-            Region(name='Test Region 1', slug='test-region-1'),
-            Region(name='Test Region 2', slug='test-region-2'),
-            Region(name='Test Region 3', slug='test-region-3'),
+            Region(name='Region 1', slug='region-1'),
+            Region(name='Region 2', slug='region-2'),
+            Region(name='Region 3', slug='region-3'),
         )
         for r in regions:
             r.save()
@@ -384,12 +384,20 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
             site_group.save()
 
         sites = (
-            Site(name='Test Site 1', slug='test-site-1'),
-            Site(name='Test Site 2', slug='test-site-2'),
-            Site(name='Test Site 3', slug='test-site-3'),
+            Site(name='Site 1', slug='site-1'),
+            Site(name='Site 2', slug='site-2'),
+            Site(name='Site 3', slug='site-3'),
         )
         Site.objects.bulk_create(sites)
 
+        locations = (
+            Location(name='Location 1', slug='location-1', site=sites[0]),
+            Location(name='Location 2', slug='location-2', site=sites[1]),
+            Location(name='Location 3', slug='location-3', site=sites[2]),
+        )
+        for location in locations:
+            location.save()
+
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         device_types = (
             DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
@@ -460,6 +468,7 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
             c.regions.set([regions[i]])
             c.site_groups.set([site_groups[i]])
             c.sites.set([sites[i]])
+            c.locations.set([locations[i]])
             c.device_types.set([device_types[i]])
             c.roles.set([device_roles[i]])
             c.platforms.set([platforms[i]])
@@ -501,6 +510,13 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'site': [sites[0].slug, sites[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_location(self):
+        locations = Location.objects.all()[:2]
+        params = {'location_id': [locations[0].pk, locations[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'location': [locations[0].slug, locations[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_device_type(self):
         device_types = DeviceType.objects.all()[:2]
         params = {'device_type_id': [device_types[0].pk, device_types[1].pk]}

+ 29 - 19
netbox/extras/tests/test_models.py

@@ -1,6 +1,6 @@
 from django.test import TestCase
 
-from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Region, Site, SiteGroup
+from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
 from extras.models import ConfigContext, Tag
 from tenancy.models import Tenant, TenantGroup
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -29,7 +29,8 @@ class ConfigContextTest(TestCase):
         self.devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
         self.region = Region.objects.create(name="Region")
         self.sitegroup = SiteGroup.objects.create(name="Site Group")
-        self.site = Site.objects.create(name='Site-1', slug='site-1', region=self.region, group=self.sitegroup)
+        self.site = Site.objects.create(name='Site 1', slug='site-1', region=self.region, group=self.sitegroup)
+        self.location = Location.objects.create(name='Location 1', slug='location-1', site=self.site)
         self.platform = Platform.objects.create(name="Platform")
         self.tenantgroup = TenantGroup.objects.create(name="Tenant Group")
         self.tenant = Tenant.objects.create(name="Tenant", group=self.tenantgroup)
@@ -40,7 +41,8 @@ class ConfigContextTest(TestCase):
             name='Device 1',
             device_type=self.devicetype,
             device_role=self.devicerole,
-            site=self.site
+            site=self.site,
+            location=self.location
         )
 
     def test_higher_weight_wins(self):
@@ -144,15 +146,6 @@ class ConfigContextTest(TestCase):
         self.assertEqual(self.device.get_config_context(), annotated_queryset[0].get_config_context())
 
     def test_annotation_same_as_get_for_object_device_relations(self):
-
-        site_context = ConfigContext.objects.create(
-            name="site",
-            weight=100,
-            data={
-                "site": 1
-            }
-        )
-        site_context.sites.add(self.site)
         region_context = ConfigContext.objects.create(
             name="region",
             weight=100,
@@ -169,6 +162,22 @@ class ConfigContextTest(TestCase):
             }
         )
         sitegroup_context.site_groups.add(self.sitegroup)
+        site_context = ConfigContext.objects.create(
+            name="site",
+            weight=100,
+            data={
+                "site": 1
+            }
+        )
+        site_context.sites.add(self.site)
+        location_context = ConfigContext.objects.create(
+            name="location",
+            weight=100,
+            data={
+                "location": 1
+            }
+        )
+        location_context.locations.add(self.location)
         platform_context = ConfigContext.objects.create(
             name="platform",
             weight=100,
@@ -205,6 +214,7 @@ class ConfigContextTest(TestCase):
         device = Device.objects.create(
             name="Device 2",
             site=self.site,
+            location=self.location,
             tenant=self.tenant,
             platform=self.platform,
             device_role=self.devicerole,
@@ -220,13 +230,6 @@ class ConfigContextTest(TestCase):
         cluster_group = ClusterGroup.objects.create(name="Cluster Group")
         cluster = Cluster.objects.create(name="Cluster", group=cluster_group, type=cluster_type)
 
-        site_context = ConfigContext.objects.create(
-            name="site",
-            weight=100,
-            data={"site": 1}
-        )
-        site_context.sites.add(self.site)
-
         region_context = ConfigContext.objects.create(
             name="region",
             weight=100,
@@ -241,6 +244,13 @@ class ConfigContextTest(TestCase):
         )
         sitegroup_context.site_groups.add(self.sitegroup)
 
+        site_context = ConfigContext.objects.create(
+            name="site",
+            weight=100,
+            data={"site": 1}
+        )
+        site_context.sites.add(self.site)
+
         platform_context = ConfigContext.objects.create(
             name="platform",
             weight=100,

+ 6 - 4
netbox/extras/tests/test_views.py

@@ -36,13 +36,15 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'default': None,
             'weight': 200,
             'required': True,
+            'ui_visibility': CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE,
         }
 
         cls.csv_data = (
-            'name,label,type,content_types,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex',
-            'field4,Field 4,text,dcim.site,100,exact,,,,[a-z]{3}',
-            'field5,Field 5,integer,dcim.site,100,exact,,1,100,',
-            'field6,Field 6,select,dcim.site,100,exact,"A,B,C",,,',
+            'name,label,type,content_types,object_type,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex,ui_visibility',
+            'field4,Field 4,text,dcim.site,,100,exact,,,,[a-z]{3},read-write',
+            'field5,Field 5,integer,dcim.site,,100,exact,,1,100,,read-write',
+            'field6,Field 6,select,dcim.site,,100,exact,"A,B,C",,,,read-write',
+            'field7,Field 7,object,dcim.site,dcim.region,100,exact,,,,,read-write',
         )
 
         cls.bulk_edit_data = {

+ 1 - 1
netbox/extras/views.py

@@ -281,6 +281,7 @@ class ConfigContextView(generic.ObjectView):
             ('Regions', instance.regions.all),
             ('Site Groups', instance.site_groups.all),
             ('Sites', instance.sites.all),
+            ('Locations', instance.locations.all),
             ('Device Types', instance.device_types.all),
             ('Roles', instance.roles.all),
             ('Platforms', instance.platforms.all),
@@ -311,7 +312,6 @@ class ConfigContextView(generic.ObjectView):
 class ConfigContextEditView(generic.ObjectEditView):
     queryset = ConfigContext.objects.all()
     form = forms.ConfigContextForm
-    template_name = 'extras/configcontext_edit.html'
 
 
 class ConfigContextBulkEditView(generic.BulkEditView):

+ 1 - 2
netbox/ipam/api/serializers.py

@@ -360,7 +360,7 @@ class IPAddressSerializer(NetBoxModelSerializer):
     )
     assigned_object = serializers.SerializerMethodField(read_only=True)
     nat_inside = NestedIPAddressSerializer(required=False, allow_null=True)
-    nat_outside = NestedIPAddressSerializer(required=False, read_only=True)
+    nat_outside = NestedIPAddressSerializer(many=True, read_only=True)
 
     class Meta:
         model = IPAddress
@@ -369,7 +369,6 @@ class IPAddressSerializer(NetBoxModelSerializer):
             'assigned_object_id', 'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'tags',
             'custom_fields', 'created', 'last_updated',
         ]
-        read_only_fields = ['family', 'nat_outside']
 
     @swagger_serializer_method(serializer_or_field=serializers.DictField)
     def get_assigned_object(self, obj):

+ 13 - 7
netbox/ipam/filtersets.py

@@ -145,9 +145,11 @@ class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
         if not value.strip():
             return queryset
         qs_filter = Q(description__icontains=value)
+        qs_filter |= Q(prefix__contains=value.strip())
         try:
             prefix = str(netaddr.IPNetwork(value.strip()).cidr)
             qs_filter |= Q(prefix__net_contains_or_equals=prefix)
+            qs_filter |= Q(prefix__contains=value.strip())
         except (AddrFormatError, ValueError):
             pass
         return queryset.filter(qs_filter)
@@ -334,9 +336,11 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
         if not value.strip():
             return queryset
         qs_filter = Q(description__icontains=value)
+        qs_filter |= Q(prefix__contains=value.strip())
         try:
             prefix = str(netaddr.IPNetwork(value.strip()).cidr)
             qs_filter |= Q(prefix__net_contains_or_equals=prefix)
+            qs_filter |= Q(prefix__contains=value.strip())
         except (AddrFormatError, ValueError):
             pass
         return queryset.filter(qs_filter)
@@ -460,7 +464,7 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
         field_name='address',
         lookup_expr='family'
     )
-    parent = django_filters.CharFilter(
+    parent = MultiValueCharFilter(
         method='search_by_parent',
         label='Parent prefix',
     )
@@ -567,14 +571,16 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
         return queryset.filter(qs_filter)
 
     def search_by_parent(self, queryset, name, value):
-        value = value.strip()
         if not value:
             return queryset
-        try:
-            query = str(netaddr.IPNetwork(value.strip()).cidr)
-            return queryset.filter(address__net_host_contained=query)
-        except (AddrFormatError, ValueError):
-            return queryset.none()
+        q = Q()
+        for prefix in value:
+            try:
+                query = str(netaddr.IPNetwork(prefix.strip()).cidr)
+                q |= Q(address__net_host_contained=query)
+            except (AddrFormatError, ValueError):
+                return queryset.none()
+        return queryset.filter(q)
 
     def filter_address(self, queryset, name, value):
         try:

+ 17 - 0
netbox/ipam/migrations/0058_ipaddress_nat_inside_nonunique.py

@@ -0,0 +1,17 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0057_created_datetimefield'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='ipaddress',
+            name='nat_inside',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='nat_outside', to='ipam.ipaddress'),
+        ),
+    ]

+ 1 - 1
netbox/ipam/models/ip.py

@@ -813,7 +813,7 @@ class IPAddress(NetBoxModel):
         ct_field='assigned_object_type',
         fk_field='assigned_object_id'
     )
-    nat_inside = models.OneToOneField(
+    nat_inside = models.ForeignKey(
         to='self',
         on_delete=models.SET_NULL,
         related_name='nat_outside',

+ 2 - 1
netbox/ipam/tables/ip.py

@@ -226,8 +226,9 @@ class PrefixUtilizationColumn(columns.UtilizationColumn):
 
 
 class PrefixTable(NetBoxTable):
-    prefix = tables.TemplateColumn(
+    prefix = columns.TemplateColumn(
         template_code=PREFIX_LINK,
+        export_raw=True,
         attrs={'td': {'class': 'text-nowrap'}}
     )
     prefix_flat = tables.TemplateColumn(

+ 4 - 2
netbox/ipam/tables/services.py

@@ -14,7 +14,8 @@ class ServiceTemplateTable(NetBoxTable):
         linkify=True
     )
     ports = tables.Column(
-        accessor=tables.A('port_list')
+        accessor=tables.A('port_list'),
+        order_by=tables.A('ports'),
     )
     tags = columns.TagColumn(
         url_name='ipam:servicetemplate_list'
@@ -35,7 +36,8 @@ class ServiceTable(NetBoxTable):
         order_by=('device', 'virtual_machine')
     )
     ports = tables.Column(
-        accessor=tables.A('port_list')
+        accessor=tables.A('port_list'),
+        order_by=tables.A('ports'),
     )
     tags = columns.TagColumn(
         url_name='ipam:service_list'

+ 2 - 4
netbox/ipam/tests/test_filtersets.py

@@ -823,10 +823,8 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_parent(self):
-        params = {'parent': '10.0.0.0/24'}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
-        params = {'parent': '2001:db8::/64'}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
+        params = {'parent': ['10.0.0.0/30', '2001:db8::/126']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
 
     def test_filter_address(self):
         # Check IPv4 and IPv6, with and without a mask

+ 22 - 5
netbox/ipam/views.py

@@ -4,15 +4,15 @@ from django.db.models.expressions import RawSQL
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 
-from circuits.models import Provider
+from circuits.models import Provider, Circuit
 from circuits.tables import ProviderTable
 from dcim.filtersets import InterfaceFilterSet
-from dcim.models import Interface, Site
+from dcim.models import Interface, Site, Device
 from dcim.tables import SiteTable
 from netbox.views import generic
 from utilities.utils import count_related
 from virtualization.filtersets import VMInterfaceFilterSet
-from virtualization.models import VMInterface
+from virtualization.models import VMInterface, VirtualMachine
 from . import filtersets, forms, tables
 from .constants import *
 from .models import *
@@ -225,7 +225,9 @@ class ASNView(generic.ObjectView):
         sites_table.configure(request)
 
         # Gather assigned Providers
-        providers = instance.providers.restrict(request.user, 'view')
+        providers = instance.providers.restrict(request.user, 'view').annotate(
+            count_circuits=count_related(Circuit, 'provider')
+        )
         providers_table = ProviderTable(providers, user=request.user)
         providers_table.configure(request)
 
@@ -585,7 +587,7 @@ class IPRangeIPAddressesView(generic.ObjectChildrenView):
 
     def get_children(self, request, parent):
         return parent.get_child_ips().restrict(request.user, 'view').prefetch_related(
-            'vrf', 'role', 'tenant',
+            'vrf', 'tenant',
         )
 
     def get_extra_context(self, request, instance):
@@ -674,11 +676,26 @@ class IPAddressView(generic.ObjectView):
         related_ips_table = tables.IPAddressTable(related_ips, orderable=False)
         related_ips_table.configure(request)
 
+        # Find services belonging to the IP
+        service_filter = Q(ipaddresses=instance)
+
+        # Find services listening on all IPs on the assigned device/vm
+        if instance.assigned_object and instance.assigned_object.parent_object:
+            parent_object = instance.assigned_object.parent_object
+
+            if isinstance(parent_object, VirtualMachine):
+                service_filter |= (Q(virtual_machine=parent_object) & Q(ipaddresses=None))
+            elif isinstance(parent_object, Device):
+                service_filter |= (Q(device=parent_object) & Q(ipaddresses=None))
+
+        services = Service.objects.restrict(request.user, 'view').filter(service_filter)
+
         return {
             'parent_prefixes_table': parent_prefixes_table,
             'duplicate_ips_table': duplicate_ips_table,
             'more_duplicate_ips': duplicate_ips.count() > 10,
             'related_ips_table': related_ips_table,
+            'services': services,
         }
 
 

+ 2 - 1
netbox/netbox/api/__init__.py

@@ -1,4 +1,4 @@
-from .fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
+from .fields import *
 from .routers import NetBoxRouter
 from .serializers import BulkOperationSerializer, ValidatedModelSerializer, WritableNestedSerializer
 
@@ -7,6 +7,7 @@ __all__ = (
     'BulkOperationSerializer',
     'ChoiceField',
     'ContentTypeField',
+    'IPNetworkSerializer',
     'NetBoxRouter',
     'SerializedPKRelatedField',
     'ValidatedModelSerializer',

+ 23 - 1
netbox/netbox/api/authentication.py

@@ -7,14 +7,36 @@ from rest_framework.permissions import BasePermission, DjangoObjectPermissions,
 
 from netbox.config import get_config
 from users.models import Token
+from utilities.request import get_client_ip
 
 
 class TokenAuthentication(authentication.TokenAuthentication):
     """
-    A custom authentication scheme which enforces Token expiration times.
+    A custom authentication scheme which enforces Token expiration times and source IP restrictions.
     """
     model = Token
 
+    def authenticate(self, request):
+        result = super().authenticate(request)
+
+        if result:
+            token = result[1]
+
+            # Enforce source IP restrictions (if any) set on the token
+            if token.allowed_ips:
+                client_ip = get_client_ip(request)
+                if client_ip is None:
+                    raise exceptions.AuthenticationFailed(
+                        "Client IP address could not be determined for validation. Check that the HTTP server is "
+                        "correctly configured to pass the required header(s)."
+                    )
+                if not token.validate_client_ip(client_ip):
+                    raise exceptions.AuthenticationFailed(
+                        f"Source IP {client_ip} is not permitted to authenticate using this token."
+                    )
+
+        return result
+
     def authenticate_credentials(self, key):
         model = self.get_model()
         try:

+ 19 - 2
netbox/netbox/api/fields.py

@@ -1,12 +1,18 @@
 from collections import OrderedDict
 
-import pytz
-from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist
+from netaddr import IPNetwork
 from rest_framework import serializers
 from rest_framework.exceptions import ValidationError
 from rest_framework.relations import PrimaryKeyRelatedField, RelatedField
 
+__all__ = (
+    'ChoiceField',
+    'ContentTypeField',
+    'IPNetworkSerializer',
+    'SerializedPKRelatedField',
+)
+
 
 class ChoiceField(serializers.Field):
     """
@@ -104,6 +110,17 @@ class ContentTypeField(RelatedField):
         return f"{obj.app_label}.{obj.model}"
 
 
+class IPNetworkSerializer(serializers.Serializer):
+    """
+    Representation of an IP network value (e.g. 192.0.2.0/24).
+    """
+    def to_representation(self, instance):
+        return str(instance)
+
+    def to_internal_value(self, value):
+        return IPNetwork(value)
+
+
 class SerializedPKRelatedField(PrimaryKeyRelatedField):
     """
     Extends PrimaryKeyRelatedField to return a serialized object on read. This is useful for representing related

+ 17 - 1
netbox/netbox/api/pagination.py

@@ -16,7 +16,7 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
     def paginate_queryset(self, queryset, request, view=None):
 
         if isinstance(queryset, QuerySet):
-            self.count = queryset.count()
+            self.count = self.get_queryset_count(queryset)
         else:
             # We're dealing with an iterable, not a QuerySet
             self.count = len(queryset)
@@ -52,6 +52,9 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
 
         return self.default_limit
 
+    def get_queryset_count(self, queryset):
+        return queryset.count()
+
     def get_next_link(self):
 
         # Pagination has been disabled
@@ -67,3 +70,16 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
             return None
 
         return super().get_previous_link()
+
+
+class StripCountAnnotationsPaginator(OptionalLimitOffsetPagination):
+    """
+    Strips the annotations on the queryset before getting the count
+    to optimize pagination of complex queries.
+    """
+    def get_queryset_count(self, queryset):
+        # Clone the queryset to avoid messing up the actual query
+        cloned_queryset = queryset.all()
+        cloned_queryset.query.annotations.clear()
+
+        return cloned_queryset.count()

+ 3 - 0
netbox/netbox/configuration_example.py

@@ -202,6 +202,9 @@ RQ_DEFAULT_TIMEOUT = 300
 # this setting is derived from the installed location.
 # SCRIPTS_ROOT = '/opt/netbox/netbox/scripts'
 
+# The name to use for the csrf token cookie.
+CSRF_COOKIE_NAME = 'csrftoken'
+
 # The name to use for the session cookie.
 SESSION_COOKIE_NAME = 'sessionid'
 

+ 5 - 0
netbox/netbox/configuration_testing.py

@@ -36,3 +36,8 @@ REDIS = {
 }
 
 SECRET_KEY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
+
+LOGGING = {
+    'version': 1,
+    'disable_existing_loggers': True
+}

+ 65 - 67
netbox/netbox/constants.py

@@ -1,32 +1,24 @@
 from collections import OrderedDict
 from typing import Dict
 
-from circuits.filtersets import CircuitFilterSet, ProviderFilterSet, ProviderNetworkFilterSet
+import circuits.filtersets
+import circuits.tables
+import dcim.filtersets
+import dcim.tables
+import ipam.filtersets
+import ipam.tables
+import tenancy.filtersets
+import tenancy.tables
+import virtualization.filtersets
+import virtualization.tables
 from circuits.models import Circuit, ProviderNetwork, Provider
-from circuits.tables import CircuitTable, ProviderNetworkTable, ProviderTable
-from dcim.filtersets import (
-    CableFilterSet, DeviceFilterSet, DeviceTypeFilterSet, LocationFilterSet, ModuleFilterSet, ModuleTypeFilterSet,
-    PowerFeedFilterSet, RackFilterSet, RackReservationFilterSet, SiteFilterSet, VirtualChassisFilterSet,
-)
 from dcim.models import (
     Cable, Device, DeviceType, Location, Module, ModuleType, PowerFeed, Rack, RackReservation, Site, VirtualChassis,
 )
-from dcim.tables import (
-    CableTable, DeviceTable, DeviceTypeTable, LocationTable, ModuleTable, ModuleTypeTable, PowerFeedTable, RackTable,
-    RackReservationTable, SiteTable, VirtualChassisTable,
-)
-from ipam.filtersets import (
-    AggregateFilterSet, ASNFilterSet, IPAddressFilterSet, PrefixFilterSet, VLANFilterSet, VRFFilterSet,
-)
-from ipam.models import Aggregate, ASN, IPAddress, Prefix, VLAN, VRF
-from ipam.tables import AggregateTable, ASNTable, IPAddressTable, PrefixTable, VLANTable, VRFTable
-from tenancy.filtersets import ContactFilterSet, TenantFilterSet
+from ipam.models import Aggregate, ASN, IPAddress, Prefix, Service, VLAN, VRF
 from tenancy.models import Contact, Tenant, ContactAssignment
-from tenancy.tables import ContactTable, TenantTable
 from utilities.utils import count_related
-from virtualization.filtersets import ClusterFilterSet, VirtualMachineFilterSet
 from virtualization.models import Cluster, VirtualMachine
-from virtualization.tables import ClusterTable, VirtualMachineTable
 
 SEARCH_MAX_RESULTS = 15
 
@@ -36,22 +28,22 @@ CIRCUIT_TYPES = OrderedDict(
             'queryset': Provider.objects.annotate(
                 count_circuits=count_related(Circuit, 'provider')
             ),
-            'filterset': ProviderFilterSet,
-            'table': ProviderTable,
+            'filterset': circuits.filtersets.ProviderFilterSet,
+            'table': circuits.tables.ProviderTable,
             'url': 'circuits:provider_list',
         }),
         ('circuit', {
             'queryset': Circuit.objects.prefetch_related(
                 'type', 'provider', 'tenant', 'terminations__site'
             ),
-            'filterset': CircuitFilterSet,
-            'table': CircuitTable,
+            'filterset': circuits.filtersets.CircuitFilterSet,
+            'table': circuits.tables.CircuitTable,
             'url': 'circuits:circuit_list',
         }),
         ('providernetwork', {
             'queryset': ProviderNetwork.objects.prefetch_related('provider'),
-            'filterset': ProviderNetworkFilterSet,
-            'table': ProviderNetworkTable,
+            'filterset': circuits.filtersets.ProviderNetworkFilterSet,
+            'table': circuits.tables.ProviderNetworkTable,
             'url': 'circuits:providernetwork_list',
         }),
     )
@@ -62,22 +54,22 @@ DCIM_TYPES = OrderedDict(
     (
         ('site', {
             'queryset': Site.objects.prefetch_related('region', 'tenant'),
-            'filterset': SiteFilterSet,
-            'table': SiteTable,
+            'filterset': dcim.filtersets.SiteFilterSet,
+            'table': dcim.tables.SiteTable,
             'url': 'dcim:site_list',
         }),
         ('rack', {
             'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'role').annotate(
                 device_count=count_related(Device, 'rack')
             ),
-            'filterset': RackFilterSet,
-            'table': RackTable,
+            'filterset': dcim.filtersets.RackFilterSet,
+            'table': dcim.tables.RackTable,
             'url': 'dcim:rack_list',
         }),
         ('rackreservation', {
             'queryset': RackReservation.objects.prefetch_related('site', 'rack', 'user'),
-            'filterset': RackReservationFilterSet,
-            'table': RackReservationTable,
+            'filterset': dcim.filtersets.RackReservationFilterSet,
+            'table': dcim.tables.RackReservationTable,
             'url': 'dcim:rackreservation_list',
         }),
         ('location', {
@@ -94,60 +86,60 @@ DCIM_TYPES = OrderedDict(
                 'rack_count',
                 cumulative=True
             ).prefetch_related('site'),
-            'filterset': LocationFilterSet,
-            'table': LocationTable,
+            'filterset': dcim.filtersets.LocationFilterSet,
+            'table': dcim.tables.LocationTable,
             'url': 'dcim:location_list',
         }),
         ('devicetype', {
             'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate(
                 instance_count=count_related(Device, 'device_type')
             ),
-            'filterset': DeviceTypeFilterSet,
-            'table': DeviceTypeTable,
+            'filterset': dcim.filtersets.DeviceTypeFilterSet,
+            'table': dcim.tables.DeviceTypeTable,
             'url': 'dcim:devicetype_list',
         }),
         ('device', {
             'queryset': Device.objects.prefetch_related(
                 'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6',
             ),
-            'filterset': DeviceFilterSet,
-            'table': DeviceTable,
+            'filterset': dcim.filtersets.DeviceFilterSet,
+            'table': dcim.tables.DeviceTable,
             'url': 'dcim:device_list',
         }),
         ('moduletype', {
             'queryset': ModuleType.objects.prefetch_related('manufacturer').annotate(
                 instance_count=count_related(Module, 'module_type')
             ),
-            'filterset': ModuleTypeFilterSet,
-            'table': ModuleTypeTable,
+            'filterset': dcim.filtersets.ModuleTypeFilterSet,
+            'table': dcim.tables.ModuleTypeTable,
             'url': 'dcim:moduletype_list',
         }),
         ('module', {
             'queryset': Module.objects.prefetch_related(
                 'module_type__manufacturer', 'device', 'module_bay',
             ),
-            'filterset': ModuleFilterSet,
-            'table': ModuleTable,
+            'filterset': dcim.filtersets.ModuleFilterSet,
+            'table': dcim.tables.ModuleTable,
             'url': 'dcim:module_list',
         }),
         ('virtualchassis', {
             'queryset': VirtualChassis.objects.prefetch_related('master').annotate(
                 member_count=count_related(Device, 'virtual_chassis')
             ),
-            'filterset': VirtualChassisFilterSet,
-            'table': VirtualChassisTable,
+            'filterset': dcim.filtersets.VirtualChassisFilterSet,
+            'table': dcim.tables.VirtualChassisTable,
             'url': 'dcim:virtualchassis_list',
         }),
         ('cable', {
             'queryset': Cable.objects.all(),
-            'filterset': CableFilterSet,
-            'table': CableTable,
+            'filterset': dcim.filtersets.CableFilterSet,
+            'table': dcim.tables.CableTable,
             'url': 'dcim:cable_list',
         }),
         ('powerfeed', {
             'queryset': PowerFeed.objects.all(),
-            'filterset': PowerFeedFilterSet,
-            'table': PowerFeedTable,
+            'filterset': dcim.filtersets.PowerFeedFilterSet,
+            'table': dcim.tables.PowerFeedTable,
             'url': 'dcim:powerfeed_list',
         }),
     )
@@ -157,40 +149,46 @@ IPAM_TYPES = OrderedDict(
     (
         ('vrf', {
             'queryset': VRF.objects.prefetch_related('tenant'),
-            'filterset': VRFFilterSet,
-            'table': VRFTable,
+            'filterset': ipam.filtersets.VRFFilterSet,
+            'table': ipam.tables.VRFTable,
             'url': 'ipam:vrf_list',
         }),
         ('aggregate', {
             'queryset': Aggregate.objects.prefetch_related('rir'),
-            'filterset': AggregateFilterSet,
-            'table': AggregateTable,
+            'filterset': ipam.filtersets.AggregateFilterSet,
+            'table': ipam.tables.AggregateTable,
             'url': 'ipam:aggregate_list',
         }),
         ('prefix', {
             'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'),
-            'filterset': PrefixFilterSet,
-            'table': PrefixTable,
+            'filterset': ipam.filtersets.PrefixFilterSet,
+            'table': ipam.tables.PrefixTable,
             'url': 'ipam:prefix_list',
         }),
         ('ipaddress', {
             'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant'),
-            'filterset': IPAddressFilterSet,
-            'table': IPAddressTable,
+            'filterset': ipam.filtersets.IPAddressFilterSet,
+            'table': ipam.tables.IPAddressTable,
             'url': 'ipam:ipaddress_list',
         }),
         ('vlan', {
             'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role'),
-            'filterset': VLANFilterSet,
-            'table': VLANTable,
+            'filterset': ipam.filtersets.VLANFilterSet,
+            'table': ipam.tables.VLANTable,
             'url': 'ipam:vlan_list',
         }),
         ('asn', {
             'queryset': ASN.objects.prefetch_related('rir', 'tenant'),
-            'filterset': ASNFilterSet,
-            'table': ASNTable,
+            'filterset': ipam.filtersets.ASNFilterSet,
+            'table': ipam.tables.ASNTable,
             'url': 'ipam:asn_list',
         }),
+        ('service', {
+            'queryset': Service.objects.prefetch_related('device', 'virtual_machine'),
+            'filterset': ipam.filtersets.ServiceFilterSet,
+            'table': ipam.tables.ServiceTable,
+            'url': 'ipam:service_list',
+        }),
     )
 )
 
@@ -198,15 +196,15 @@ TENANCY_TYPES = OrderedDict(
     (
         ('tenant', {
             'queryset': Tenant.objects.prefetch_related('group'),
-            'filterset': TenantFilterSet,
-            'table': TenantTable,
+            'filterset': tenancy.filtersets.TenantFilterSet,
+            'table': tenancy.tables.TenantTable,
             'url': 'tenancy:tenant_list',
         }),
         ('contact', {
             'queryset': Contact.objects.prefetch_related('group', 'assignments').annotate(
                 assignment_count=count_related(ContactAssignment, 'contact')),
-            'filterset': ContactFilterSet,
-            'table': ContactTable,
+            'filterset': tenancy.filtersets.ContactFilterSet,
+            'table': tenancy.tables.ContactTable,
             'url': 'tenancy:contact_list',
         }),
     )
@@ -219,16 +217,16 @@ VIRTUALIZATION_TYPES = OrderedDict(
                 device_count=count_related(Device, 'cluster'),
                 vm_count=count_related(VirtualMachine, 'cluster')
             ),
-            'filterset': ClusterFilterSet,
-            'table': ClusterTable,
+            'filterset': virtualization.filtersets.ClusterFilterSet,
+            'table': virtualization.tables.ClusterTable,
             'url': 'virtualization:cluster_list',
         }),
         ('virtualmachine', {
             'queryset': VirtualMachine.objects.prefetch_related(
                 'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
             ),
-            'filterset': VirtualMachineFilterSet,
-            'table': VirtualMachineTable,
+            'filterset': virtualization.filtersets.VirtualMachineFilterSet,
+            'table': virtualization.tables.VirtualMachineTable,
             'url': 'virtualization:virtualmachine_list',
         }),
     )

+ 18 - 2
netbox/netbox/models/features.py

@@ -1,3 +1,5 @@
+from collections import defaultdict
+
 from django.contrib.contenttypes.fields import GenericRelation
 from django.db.models.signals import class_prepared
 from django.dispatch import receiver
@@ -7,7 +9,7 @@ from django.core.validators import ValidationError
 from django.db import models
 from taggit.managers import TaggableManager
 
-from extras.choices import ObjectChangeActionChoices
+from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices
 from extras.utils import register_features
 from netbox.signals import post_clean
 from utilities.utils import serialize_object
@@ -98,7 +100,7 @@ class CustomFieldsMixin(models.Model):
         """
         return self.custom_field_data
 
-    def get_custom_fields(self):
+    def get_custom_fields(self, omit_hidden=False):
         """
         Return a dictionary of custom fields for a single object in the form `{field: value}`.
 
@@ -112,11 +114,25 @@ class CustomFieldsMixin(models.Model):
 
         data = {}
         for field in CustomField.objects.get_for_model(self):
+            # Skip fields that are hidden if 'omit_hidden' is set
+            if omit_hidden and field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN:
+                continue
+
             value = self.custom_field_data.get(field.name)
             data[field] = field.deserialize(value)
 
         return data
 
+    def get_custom_fields_by_group(self):
+        """
+        Return a dictionary of custom field/value mappings organized by group. Hidden fields are omitted.
+        """
+        grouped_custom_fields = defaultdict(dict)
+        for cf, value in self.get_custom_fields(omit_hidden=True).items():
+            grouped_custom_fields[cf.group_name][cf] = value
+
+        return dict(grouped_custom_fields)
+
     def clean(self):
         super().clean()
         from extras.models import CustomField

+ 6 - 1
netbox/netbox/settings.py

@@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str
 # Environment setup
 #
 
-VERSION = '3.2.3'
+VERSION = '3.3.0-dev'
 
 # Hostname
 HOSTNAME = platform.node()
@@ -84,6 +84,7 @@ if BASE_PATH:
 CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False)
 CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', [])
 CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
+CSRF_COOKIE_NAME = getattr(configuration, 'CSRF_COOKIE_NAME', 'csrftoken')
 CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', [])
 DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y')
 DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
@@ -95,6 +96,7 @@ EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
 FIELD_CHOICES = getattr(configuration, 'FIELD_CHOICES', {})
 HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None)
 INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))
+JINJA2_FILTERS = getattr(configuration, 'JINJA2_FILTERS', {})
 LOGGING = getattr(configuration, 'LOGGING', {})
 LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False)
 LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
@@ -422,6 +424,8 @@ LOGIN_REDIRECT_URL = f'/{BASE_PATH}'
 
 DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
 
+TEST_RUNNER = "django_rich.test.RichRunner"
+
 # Exclude potentially sensitive models from wildcard view exemption. These may still be exempted
 # by specifying the model individually in the EXEMPT_VIEW_PERMISSIONS configuration parameter.
 EXEMPT_EXCLUDE_MODELS = (
@@ -511,6 +515,7 @@ REST_FRAMEWORK = {
     ),
     'DEFAULT_FILTER_BACKENDS': (
         'django_filters.rest_framework.DjangoFilterBackend',
+        'rest_framework.filters.OrderingFilter',
     ),
     'DEFAULT_METADATA_CLASS': 'netbox.api.metadata.BulkOperationMetadata',
     'DEFAULT_PAGINATION_CLASS': 'netbox.api.pagination.OptionalLimitOffsetPagination',

+ 63 - 16
netbox/netbox/tables/columns.py

@@ -90,6 +90,15 @@ class TemplateColumn(tables.TemplateColumn):
     """
     PLACEHOLDER = mark_safe('&mdash;')
 
+    def __init__(self, export_raw=False, **kwargs):
+        """
+        Args:
+            export_raw: If true, data export returns the raw field value rather than the rendered template. (Default:
+                        False)
+        """
+        super().__init__(**kwargs)
+        self.export_raw = export_raw
+
     def render(self, *args, **kwargs):
         ret = super().render(*args, **kwargs)
         if not ret.strip():
@@ -97,6 +106,10 @@ class TemplateColumn(tables.TemplateColumn):
         return ret
 
     def value(self, **kwargs):
+        if self.export_raw:
+            # Skip template rendering and export raw value
+            return kwargs.get('value')
+
         ret = super().value(**kwargs)
         if ret == self.PLACEHOLDER:
             return ''
@@ -153,6 +166,7 @@ class ActionsItem:
     title: str
     icon: str
     permission: Optional[str] = None
+    css_class: Optional[str] = 'secondary'
 
 
 class ActionsColumn(tables.Column):
@@ -162,19 +176,22 @@ class ActionsColumn(tables.Column):
 
     :param actions: The ordered list of dropdown menu items to include
     :param extra_buttons: A Django template string which renders additional buttons preceding the actions dropdown
+    :param split_actions: When True, converts the actions dropdown menu into a split button with first action as the
+        direct button link and icon (default: True)
     """
     attrs = {'td': {'class': 'text-end text-nowrap noprint'}}
     empty_values = ()
     actions = {
-        'edit': ActionsItem('Edit', 'pencil', 'change'),
-        'delete': ActionsItem('Delete', 'trash-can-outline', 'delete'),
+        'edit': ActionsItem('Edit', 'pencil', 'change', 'warning'),
+        'delete': ActionsItem('Delete', 'trash-can-outline', 'delete', 'danger'),
         'changelog': ActionsItem('Changelog', 'history'),
     }
 
-    def __init__(self, *args, actions=('edit', 'delete', 'changelog'), extra_buttons='', **kwargs):
+    def __init__(self, *args, actions=('edit', 'delete', 'changelog'), extra_buttons='', split_actions=True, **kwargs):
         super().__init__(*args, **kwargs)
 
         self.extra_buttons = extra_buttons
+        self.split_actions = split_actions
 
         # Determine which actions to enable
         self.actions = {
@@ -192,32 +209,62 @@ class ActionsColumn(tables.Column):
         model = table.Meta.model
         request = getattr(table, 'context', {}).get('request')
         url_appendix = f'?return_url={request.path}' if request else ''
+        html = ''
 
-        links = []
+        # Compile actions menu
+        button = None
+        dropdown_class = 'secondary'
+        dropdown_links = []
         user = getattr(request, 'user', AnonymousUser())
-        for action, attrs in self.actions.items():
+        for idx, (action, attrs) in enumerate(self.actions.items()):
             permission = f'{model._meta.app_label}.{attrs.permission}_{model._meta.model_name}'
             if attrs.permission is None or user.has_perm(permission):
                 url = reverse(get_viewname(model, action), kwargs={'pk': record.pk})
-                links.append(f'<li><a class="dropdown-item" href="{url}{url_appendix}">'
-                             f'<i class="mdi mdi-{attrs.icon}"></i> {attrs.title}</a></li>')
-
-        if not links:
-            return ''
 
-        menu = f'<span class="dropdown">' \
-               f'<a class="btn btn-sm btn-secondary dropdown-toggle" href="#" type="button" data-bs-toggle="dropdown">' \
-               f'<i class="mdi mdi-wrench"></i></a>' \
-               f'<ul class="dropdown-menu">{"".join(links)}</ul></span>'
+                # Render a separate button if a) only one action exists, or b) if split_actions is True
+                if len(self.actions) == 1 or (self.split_actions and idx == 0):
+                    dropdown_class = attrs.css_class
+                    button = (
+                        f'<a class="btn btn-sm btn-{attrs.css_class}" href="{url}{url_appendix}" type="button">'
+                        f'<i class="mdi mdi-{attrs.icon}"></i></a>'
+                    )
+
+                # Add dropdown menu items
+                else:
+                    dropdown_links.append(
+                        f'<li><a class="dropdown-item" href="{url}{url_appendix}">'
+                        f'<i class="mdi mdi-{attrs.icon}"></i> {attrs.title}</a></li>'
+                    )
+
+        # Create the actions dropdown menu
+        if button and dropdown_links:
+            html += (
+                f'<span class="btn-group dropdown">'
+                f'  {button}'
+                f'  <a class="btn btn-sm btn-{dropdown_class} dropdown-toggle" type="button" data-bs-toggle="dropdown" style="padding-left: 2px">'
+                f'  <span class="visually-hidden">Toggle Dropdown</span></a>'
+                f'  <ul class="dropdown-menu">{"".join(dropdown_links)}</ul>'
+                f'</span>'
+            )
+        elif button:
+            html += button
+        elif dropdown_links:
+            html += (
+                f'<span class="btn-group dropdown">'
+                f'  <a class="btn btn-sm btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">'
+                f'  <span class="visually-hidden">Toggle Dropdown</span></a>'
+                f'  <ul class="dropdown-menu">{"".join(dropdown_links)}</ul>'
+                f'</span>'
+            )
 
         # Render any extra buttons from template code
         if self.extra_buttons:
             template = Template(self.extra_buttons)
             context = getattr(table, "context", Context())
             context.update({'record': record})
-            menu = template.render(context) + menu
+            html = template.render(context) + html
 
-        return mark_safe(menu)
+        return mark_safe(html)
 
 
 class ChoiceFieldColumn(tables.Column):

Некоторые файлы не были показаны из-за большого количества измененных файлов