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

Merge branch 'feature' into 9102-cabling

jeremystretch 3 лет назад
Родитель
Сommit
ba079b9ee5
100 измененных файлов с 2239 добавлено и 323 удалено
  1. 6 0
      NOTICE
  2. 1 1
      docs/administration/permissions.md
  3. 36 12
      docs/configuration/dynamic-settings.md
  4. 5 0
      docs/core-functionality/ipam.md
  5. 2 0
      docs/development/models.md
  6. 21 0
      docs/models/ipam/l2vpn.md
  7. 15 0
      docs/models/ipam/l2vpntermination.md
  8. 14 0
      docs/models/users/objectpermission.md
  9. 28 0
      docs/plugins/development/exceptions.md
  10. 2 0
      docs/plugins/development/templates.md
  11. 16 9
      docs/plugins/development/views.md
  12. 13 0
      docs/release-notes/version-3.2.md
  13. 21 1
      docs/release-notes/version-3.3.md
  14. 1 0
      mkdocs.yml
  15. 2 2
      netbox/circuits/api/serializers.py
  16. 1 1
      netbox/circuits/filtersets.py
  17. 2 2
      netbox/circuits/forms/models.py
  18. 2 1
      netbox/circuits/graphql/types.py
  19. 0 18
      netbox/circuits/migrations/0036_circuit_termination_date.py
  20. 28 0
      netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py
  21. 1 1
      netbox/circuits/migrations/0037_new_cabling_models.py
  22. 11 2
      netbox/circuits/models/circuits.py
  23. 9 0
      netbox/dcim/api/nested_serializers.py
  24. 8 5
      netbox/dcim/api/serializers.py
  25. 1 1
      netbox/dcim/api/views.py
  26. 0 9
      netbox/dcim/constants.py
  27. 6 0
      netbox/dcim/filtersets.py
  28. 16 1
      netbox/dcim/forms/models.py
  29. 3 3
      netbox/dcim/migrations/0001_squashed.py
  30. 10 0
      netbox/dcim/models/device_components.py
  31. 4 3
      netbox/dcim/models/power.py
  32. 5 0
      netbox/dcim/tests/test_filtersets.py
  33. 3 0
      netbox/extras/admin.py
  34. 10 3
      netbox/extras/webhooks.py
  35. 28 0
      netbox/ipam/api/nested_serializers.py
  36. 57 2
      netbox/ipam/api/serializers.py
  37. 4 0
      netbox/ipam/api/urls.py
  38. 13 0
      netbox/ipam/api/views.py
  39. 49 0
      netbox/ipam/choices.py
  40. 6 0
      netbox/ipam/constants.py
  41. 112 0
      netbox/ipam/filtersets.py
  42. 23 0
      netbox/ipam/forms/bulk_edit.py
  43. 83 0
      netbox/ipam/forms/bulk_import.py
  44. 42 1
      netbox/ipam/forms/filtersets.py
  45. 111 1
      netbox/ipam/forms/models.py
  46. 6 0
      netbox/ipam/graphql/schema.py
  47. 16 0
      netbox/ipam/graphql/types.py
  48. 62 0
      netbox/ipam/migrations/0059_l2vpn.py
  49. 3 0
      netbox/ipam/models/__init__.py
  50. 28 0
      netbox/ipam/models/ip.py
  51. 112 0
      netbox/ipam/models/l2vpn.py
  52. 13 1
      netbox/ipam/models/vlans.py
  53. 1 0
      netbox/ipam/tables/__init__.py
  54. 57 0
      netbox/ipam/tables/l2vpn.py
  55. 95 0
      netbox/ipam/tests/test_api.py
  56. 97 0
      netbox/ipam/tests/test_filtersets.py
  57. 75 1
      netbox/ipam/tests/test_models.py
  58. 136 2
      netbox/ipam/tests/test_views.py
  59. 22 0
      netbox/ipam/urls.py
  60. 112 6
      netbox/ipam/views.py
  61. 9 0
      netbox/netbox/api/viewsets/__init__.py
  62. 36 13
      netbox/netbox/authentication.py
  63. 25 0
      netbox/netbox/config/parameters.py
  64. 11 3
      netbox/netbox/models/features.py
  65. 7 0
      netbox/netbox/navigation_menu.py
  66. 13 0
      netbox/netbox/settings.py
  67. 33 59
      netbox/netbox/views/generic/bulk_views.py
  68. 48 0
      netbox/netbox/views/generic/mixins.py
  69. 33 27
      netbox/netbox/views/generic/object_views.py
  70. 70 66
      netbox/templates/circuits/circuit.html
  71. 8 0
      netbox/templates/circuits/circuittermination_edit.html
  72. 30 3
      netbox/templates/circuits/inc/circuit_termination.html
  73. 2 2
      netbox/templates/dcim/device/consoleports.html
  74. 2 2
      netbox/templates/dcim/device/consoleserverports.html
  75. 2 2
      netbox/templates/dcim/device/devicebays.html
  76. 2 2
      netbox/templates/dcim/device/frontports.html
  77. 2 2
      netbox/templates/dcim/device/interfaces.html
  78. 2 2
      netbox/templates/dcim/device/inventory.html
  79. 2 2
      netbox/templates/dcim/device/modulebays.html
  80. 2 2
      netbox/templates/dcim/device/poweroutlets.html
  81. 2 2
      netbox/templates/dcim/device/powerports.html
  82. 2 2
      netbox/templates/dcim/device/rearports.html
  83. 9 0
      netbox/templates/dcim/device_edit.html
  84. 4 0
      netbox/templates/dcim/interface.html
  85. 8 0
      netbox/templates/dcim/interface_edit.html
  86. 1 27
      netbox/templates/inc/panels/custom_fields.html
  87. 2 2
      netbox/templates/ipam/aggregate/prefixes.html
  88. 2 2
      netbox/templates/ipam/iprange/ip_addresses.html
  89. 81 0
      netbox/templates/ipam/l2vpn.html
  90. 31 0
      netbox/templates/ipam/l2vpntermination.html
  91. 49 0
      netbox/templates/ipam/l2vpntermination_edit.html
  92. 2 2
      netbox/templates/ipam/prefix/ip_addresses.html
  93. 2 2
      netbox/templates/ipam/prefix/ip_ranges.html
  94. 2 2
      netbox/templates/ipam/prefix/prefixes.html
  95. 4 0
      netbox/templates/ipam/vlan.html
  96. 2 2
      netbox/templates/virtualization/cluster/virtual_machines.html
  97. 6 3
      netbox/users/admin/forms.py
  98. 2 0
      netbox/users/constants.py
  99. 16 1
      netbox/utilities/exceptions.py
  100. 7 0
      netbox/utilities/management/commands/__init__.py

+ 6 - 0
NOTICE

@@ -1 +1,7 @@
 Copyrighted and licensed under Apache License 2.0 by DigitalOcean, LLC.
 Copyrighted and licensed under Apache License 2.0 by DigitalOcean, LLC.
+
+This project contains code developed expressly for NetBox, and its reuse in
+other projects may introduce issues affecting performance, data integrity,
+and security.
+
+For more information, please see https://github.com/netbox-community/netbox.

+ 1 - 1
docs/administration/permissions.md

@@ -4,7 +4,7 @@ NetBox v2.9 introduced a new object-based permissions framework, which replaces
 
 
 {!models/users/objectpermission.md!}
 {!models/users/objectpermission.md!}
 
 
-### Example Constraint Definitions
+#### Example Constraint Definitions
 
 
 | Constraints | Description |
 | Constraints | Description |
 | ----------- | ----------- |
 | ----------- | ----------- |

+ 36 - 12
docs/configuration/dynamic-settings.md

@@ -43,18 +43,6 @@ changes in the database indefinitely.
 
 
 ---
 ---
 
 
-## JOBRESULT_RETENTION
-
-Default: 90
-
-The number of days to retain job results (scripts and reports). Set this to `0` to retain
-job results in the database indefinitely.
-
-!!! warning
-    If enabling indefinite job results retention, it is recommended to periodically delete old entries. Otherwise, the database may eventually exceed capacity.
-
----
-
 ## CUSTOM_VALIDATORS
 ## CUSTOM_VALIDATORS
 
 
 This is a mapping of models to [custom validators](../customization/custom-validation.md) that have been defined locally to enforce custom validation logic. An example is provided below:
 This is a mapping of models to [custom validators](../customization/custom-validation.md) that have been defined locally to enforce custom validation logic. An example is provided below:
@@ -110,6 +98,18 @@ Setting this to False will disable the GraphQL API.
 
 
 ---
 ---
 
 
+## JOBRESULT_RETENTION
+
+Default: 90
+
+The number of days to retain job results (scripts and reports). Set this to `0` to retain
+job results in the database indefinitely.
+
+!!! warning
+    If enabling indefinite job results retention, it is recommended to periodically delete old entries. Otherwise, the database may eventually exceed capacity.
+
+---
+
 ## MAINTENANCE_MODE
 ## MAINTENANCE_MODE
 
 
 Default: False
 Default: False
@@ -185,6 +185,30 @@ The default maximum number of objects to display per page within each list of ob
 
 
 ---
 ---
 
 
+## POWERFEED_DEFAULT_AMPERAGE
+
+Default: 15
+
+The default value for the `amperage` field when creating new power feeds.
+
+---
+
+## POWERFEED_DEFAULT_MAX_UTILIZATION
+
+Default: 80
+
+The default value (percentage) for the `max_utilization` field when creating new power feeds.
+
+---
+
+## POWERFEED_DEFAULT_VOLTAGE
+
+Default: 120
+
+The default value for the `voltage` field when creating new power feeds.
+
+---
+
 ## PREFER_IPV4
 ## PREFER_IPV4
 
 
 Default: False
 Default: False

+ 5 - 0
docs/core-functionality/ipam.md

@@ -26,3 +26,8 @@
 ---
 ---
 
 
 {!models/ipam/asn.md!}
 {!models/ipam/asn.md!}
+
+---
+
+{!models/ipam/l2vpn.md!}
+{!models/ipam/l2vpntermination.md!}

+ 2 - 0
docs/development/models.md

@@ -45,6 +45,8 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
 * [ipam.FHRPGroup](../models/ipam/fhrpgroup.md)
 * [ipam.FHRPGroup](../models/ipam/fhrpgroup.md)
 * [ipam.IPAddress](../models/ipam/ipaddress.md)
 * [ipam.IPAddress](../models/ipam/ipaddress.md)
 * [ipam.IPRange](../models/ipam/iprange.md)
 * [ipam.IPRange](../models/ipam/iprange.md)
+* [ipam.L2VPN](../models/ipam/l2vpn.md)
+* [ipam.L2VPNTermination](../models/ipam/l2vpntermination.md)
 * [ipam.Prefix](../models/ipam/prefix.md)
 * [ipam.Prefix](../models/ipam/prefix.md)
 * [ipam.RouteTarget](../models/ipam/routetarget.md)
 * [ipam.RouteTarget](../models/ipam/routetarget.md)
 * [ipam.Service](../models/ipam/service.md)
 * [ipam.Service](../models/ipam/service.md)

+ 21 - 0
docs/models/ipam/l2vpn.md

@@ -0,0 +1,21 @@
+# L2VPN
+
+A L2VPN object is NetBox is a representation of a layer 2 bridge technology such as VXLAN, VPLS or EPL.  Each L2VPN can be identified by name as well as an optional unique identifier (VNI would be an example).
+
+Each L2VPN instance must have one of the following type associated with it:
+
+* VPLS
+* VPWS
+* EPL
+* EVPL
+* EP-LAN
+* EVP-LAN
+* EP-TREE
+* EVP-TREE
+* VXLAN
+* VXLAN EVPN
+* MPLS-EVPN
+* PBB-EVPN
+
+!!!note
+    Choosing VPWS, EPL, EP-LAN, EP-TREE will result in only being able to add 2 terminations to a given L2VPN.

+ 15 - 0
docs/models/ipam/l2vpntermination.md

@@ -0,0 +1,15 @@
+# L2VPN Termination
+
+A L2VPN Termination is the termination point of a L2VPN.  Certain types of L2VPN's may only have 2 termination points (point-to-point) while others may have many terminations (multipoint).
+
+Each termination consists of a L2VPN it is a member of as well as the connected endpoint which can be an interface or a VLAN.
+
+The following types of L2VPN's are considered point-to-point:
+
+* VPWS
+* EPL
+* EP-LAN
+* EP-TREE
+
+!!!note
+    Choosing any of the above types of L2VPN's will result in only being able to add 2 terminations to a given L2VPN.

+ 14 - 0
docs/models/users/objectpermission.md

@@ -53,3 +53,17 @@ To achieve a logical OR with a different set of constraints, define multiple obj
 ```
 ```
 
 
 Additionally, where multiple permissions have been assigned for an object type, their collective constraints will be merged using a logical "OR" operation.
 Additionally, where multiple permissions have been assigned for an object type, their collective constraints will be merged using a logical "OR" operation.
+
+### Tokens
+
+!!! info "This feature was introduced in NetBox v3.3"
+
+When defining a permission constraint, administrators may use the special token `$user` to reference the current user at the time of evaluation. This can be helpful to restrict users to editing only their own journal entries, for example. Such a constraint might be defined as:
+
+```json
+{
+  "created_by": "$user"
+}
+```
+
+The `$user` token can be used only as a constraint value, or as an item within a list of values. It cannot be modified or extended to reference specific user attributes.

+ 28 - 0
docs/plugins/development/exceptions.md

@@ -0,0 +1,28 @@
+# Exceptions
+
+The exception classes listed here may be raised by a plugin to alter NetBox's default behavior in various scenarios.
+
+## `AbortRequest`
+
+NetBox provides several [generic views](./views.md) and [REST API viewsets](./rest-api.md) which facilitate the creation, modification, and deletion of objects, either individually or in bulk. Under certain conditions, it may be desirable for a plugin to interrupt these actions and cleanly abort the request, reporting an error message to the end user or API consumer.
+
+For example, a plugin may prohibit the creation of a site with a prohibited name by connecting a receiver to Django's `pre_save` signal for the Site model:
+
+```python
+from django.db.models.signals import pre_save
+from django.dispatch import receiver
+from dcim.models import Site
+from utilities.exceptions import AbortRequest
+
+PROHIBITED_NAMES = ('foo', 'bar', 'baz')
+
+@receiver(pre_save, sender=Site)
+def test_abort_request(instance, **kwargs):
+    if instance.name.lower() in PROHIBITED_NAMES:
+        raise AbortRequest(f"Site name can't be {instance.name}!")
+```
+
+An error message must be supplied when raising `AbortRequest`. This will be conveyed to the user and should clearly explain the reason for which the request was aborted, as well as any potential remedy.
+
+!!! tip "Consider custom validation rules"
+    This exception is intended to be used for handling complex evaluation logic and should be used sparingly. For simple object validation (such as the contrived example above), consider using [custom validation rules](../../customization/custom-validation.md) instead.

+ 2 - 0
docs/plugins/development/templates.md

@@ -215,6 +215,8 @@ The following custom template tags are available in NetBox.
 
 
 ::: utilities.templatetags.builtins.tags.checkmark
 ::: utilities.templatetags.builtins.tags.checkmark
 
 
+::: utilities.templatetags.builtins.tags.customfield_value
+
 ::: utilities.templatetags.builtins.tags.tag
 ::: utilities.templatetags.builtins.tags.tag
 
 
 ## Filters
 ## Filters

+ 16 - 9
docs/plugins/development/views.md

@@ -51,15 +51,16 @@ This makes our view accessible at the URL `/plugins/animal-sounds/random/`. (Rem
 
 
 NetBox provides several generic view classes (documented below) to facilitate common operations, such as creating, viewing, modifying, and deleting objects. Plugins can subclass these views for their own use.
 NetBox provides several generic view classes (documented below) to facilitate common operations, such as creating, viewing, modifying, and deleting objects. Plugins can subclass these views for their own use.
 
 
-| View Class         | Description                    |
-|--------------------|--------------------------------|
-| `ObjectView`       | View a single object           |
-| `ObjectEditView`   | Create or edit a single object |
-| `ObjectDeleteView` | Delete a single object         |
-| `ObjectListView`   | View a list of objects         |
-| `BulkImportView`   | Import a set of new objects    |
-| `BulkEditView`     | Edit multiple objects          |
-| `BulkDeleteView`   | Delete multiple objects        |
+| View Class           | Description                                            |
+|----------------------|--------------------------------------------------------|
+| `ObjectView`         | View a single object                                   |
+| `ObjectEditView`     | Create or edit a single object                         |
+| `ObjectDeleteView`   | Delete a single object                                 |
+| `ObjectChildrenView` | A list of child objects within the context of a parent |
+| `ObjectListView`     | View a list of objects                                 |
+| `BulkImportView`     | Import a set of new objects                            |
+| `BulkEditView`       | Edit multiple objects                                  |
+| `BulkDeleteView`     | Delete multiple objects                                |
 
 
 !!! warning
 !!! warning
     Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `views.generic` module, they are not yet supported for use by plugins.
     Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `views.generic` module, they are not yet supported for use by plugins.
@@ -99,6 +100,12 @@ Below are the class definitions for NetBox's object views. These views handle CR
       members:
       members:
         - get_object
         - get_object
 
 
+::: netbox.views.generic.ObjectChildrenView
+    selection:
+      members:
+        - get_children
+        - prep_table_data
+
 ## Multi-Object Views
 ## Multi-Object Views
 
 
 Below are the class definitions for NetBox's multi-object views. These views handle simultaneous actions for sets objects. The list, import, edit, and delete views each inherit from `BaseMultiObjectView`, which is not intended to be used directly.
 Below are the class definitions for NetBox's multi-object views. These views handle simultaneous actions for sets objects. The list, import, edit, and delete views each inherit from `BaseMultiObjectView`, which is not intended to be used directly.

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

@@ -2,6 +2,19 @@
 
 
 ## v3.2.6 (FUTURE)
 ## v3.2.6 (FUTURE)
 
 
+### Enhancements
+
+* [#7702](https://github.com/netbox-community/netbox/issues/7702) - Enable dynamic configuration for default powerfeed attributes
+* [#9396](https://github.com/netbox-community/netbox/issues/9396) - Allow filtering modules by bay ID
+* [#9403](https://github.com/netbox-community/netbox/issues/9403) - Enable modifying virtual chassis properties when creating/editing a device
+* [#9540](https://github.com/netbox-community/netbox/issues/9540) - Add filters for assigned device & VM to IP addresses list
+
+### Bug Fixes
+
+* [#8854](https://github.com/netbox-community/netbox/issues/8854) - Fix `REMOTE_AUTH_DEFAULT_GROUPS` for social-auth backends
+* [#9575](https://github.com/netbox-community/netbox/issues/9575) - Fix AttributeError exception for FHRP group with an IP address assigned
+* [#9597](https://github.com/netbox-community/netbox/issues/9597) - Include `installed_module` in module bay REST API serializer
+
 ---
 ---
 
 
 ## v3.2.5 (2022-06-20)
 ## v3.2.5 (2022-06-20)

+ 21 - 1
docs/release-notes/version-3.3.md

@@ -13,8 +13,12 @@
 
 
 #### PoE Interface Attributes ([#1099](https://github.com/netbox-community/netbox/issues/1099))
 #### PoE Interface Attributes ([#1099](https://github.com/netbox-community/netbox/issues/1099))
 
 
+#### L2VPN Modeling ([#8157](https://github.com/netbox-community/netbox/issues/8157))
+
 #### Restrict API Tokens by Client IP ([#8233](https://github.com/netbox-community/netbox/issues/8233))
 #### Restrict API Tokens by Client IP ([#8233](https://github.com/netbox-community/netbox/issues/8233))
 
 
+#### Reference User in Permission Constraints ([#9074](https://github.com/netbox-community/netbox/issues/9074))
+
 ### Enhancements
 ### Enhancements
 
 
 * [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses
 * [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses
@@ -23,10 +27,13 @@
 * [#5303](https://github.com/netbox-community/netbox/issues/5303) - A virtual machine may be assigned to a site and/or cluster
 * [#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
 * [#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
 * [#7744](https://github.com/netbox-community/netbox/issues/7744) - Add `status` field to Location
+* [#8171](https://github.com/netbox-community/netbox/issues/8171) - Populate next available address when cloning an IP
 * [#8222](https://github.com/netbox-community/netbox/issues/8222) - Enable the assignment of a VM to a specific host device within a cluster
 * [#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
 * [#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
 * [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping
+* [#8511](https://github.com/netbox-community/netbox/issues/8511) - Enable custom fields and tags for circuit terminations
 * [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results
 * [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results
+* [#9070](https://github.com/netbox-community/netbox/issues/9070) - Hide navigation menu items based on user permissions
 * [#9166](https://github.com/netbox-community/netbox/issues/9166) - Add UI visibility toggle for custom fields
 * [#9166](https://github.com/netbox-community/netbox/issues/9166) - Add UI visibility toggle for custom fields
 * [#9177](https://github.com/netbox-community/netbox/issues/9177) - Add tenant assignment for wireless LANs & links
 * [#9177](https://github.com/netbox-community/netbox/issues/9177) - Add tenant assignment for wireless LANs & links
 * [#9536](https://github.com/netbox-community/netbox/issues/9536) - Track API token usage times
 * [#9536](https://github.com/netbox-community/netbox/issues/9536) - Track API token usage times
@@ -34,7 +41,11 @@
 
 
 ### Plugins API
 ### Plugins API
 
 
+* [#9075](https://github.com/netbox-community/netbox/issues/9075) - Introduce `AbortRequest` exception for cleanly interrupting object mutations
+* [#9092](https://github.com/netbox-community/netbox/issues/9092) - Add support for `ObjectChildrenView` generic view
+* [#9228](https://github.com/netbox-community/netbox/issues/9228) - Subclasses of `ChangeLoggingMixin` can override `serialize_object()` to control JSON serialization for change logging
 * [#9414](https://github.com/netbox-community/netbox/issues/9414) - Add `clone()` method to NetBoxModel for copying instance attributes
 * [#9414](https://github.com/netbox-community/netbox/issues/9414) - Add `clone()` method to NetBoxModel for copying instance attributes
+* [#9647](https://github.com/netbox-community/netbox/issues/9647) - Introduce `customfield_value` template tag
 
 
 ### Other Changes
 ### Other Changes
 
 
@@ -43,14 +54,20 @@
 
 
 ### REST API Changes
 ### REST API Changes
 
 
+* Added the following endpoints:
+    * `/api/ipam/l2vpns/`
+    * `/api/ipam/l2vpn-terminations/`
 * circuits.Circuit
 * circuits.Circuit
     * Added optional `termination_date` field
     * Added optional `termination_date` field
+* circuits.CircuitTermination
+    * Added 'custom_fields' and 'tags' fields
 * dcim.Device
 * dcim.Device
     * The `position` field has been changed from an integer to a decimal
     * The `position` field has been changed from an integer to a decimal
 * dcim.DeviceType
 * dcim.DeviceType
     * The `u_height` field has been changed from an integer to a decimal
     * The `u_height` field has been changed from an integer to a decimal
 * dcim.Interface
 * dcim.Interface
     * Added the optional `poe_mode` and `poe_type` fields
     * Added the optional `poe_mode` and `poe_type` fields
+    * Added the `l2vpn_termination` read-only field
 * dcim.Location
 * dcim.Location
     * Added required `status` field (default value: `active`)
     * Added required `status` field (default value: `active`)
 * dcim.Rack
 * dcim.Rack
@@ -62,15 +79,18 @@
 * ipam.IPAddress
 * ipam.IPAddress
     * The `nat_inside` field no longer requires a unique value
     * 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
     * The `nat_outside` field has changed from a single IP address instance to a list of multiple IP addresses
+* ipam.VLAN
+    * Added the `l2vpn_termination` read-only field
 * users.Token
 * users.Token
     * Added the `allowed_ips` array field
     * Added the `allowed_ips` array field
     * Added the read-only `last_used` datetime field
     * Added the read-only `last_used` datetime field
 * virtualization.Cluster
 * virtualization.Cluster
     * Added required `status` field (default value: `active`)
     * Added required `status` field (default value: `active`)
 * virtualization.VirtualMachine
 * virtualization.VirtualMachine
-    * Added `device` field
     * The `site` field is now directly writable (rather than being inferred from the assigned cluster)
     * 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.
     * The `cluster` field is now optional. A virtual machine must have a site and/or cluster assigned.
+    * Added the `device` field
+    * Added the `l2vpn_termination` read-only field
 wireless.WirelessLAN
 wireless.WirelessLAN
     * Added `tenant` field
     * Added `tenant` field
 wireless.WirelessLink
 wireless.WirelessLink

+ 1 - 0
mkdocs.yml

@@ -118,6 +118,7 @@ nav:
             - REST API: 'plugins/development/rest-api.md'
             - REST API: 'plugins/development/rest-api.md'
             - GraphQL API: 'plugins/development/graphql-api.md'
             - GraphQL API: 'plugins/development/graphql-api.md'
             - Background Tasks: 'plugins/development/background-tasks.md'
             - Background Tasks: 'plugins/development/background-tasks.md'
+            - Exceptions: 'plugins/development/exceptions.md'
     - Administration:
     - Administration:
         - Authentication:
         - Authentication:
             - Overview: 'administration/authentication/overview.md'
             - Overview: 'administration/authentication/overview.md'

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

@@ -98,7 +98,7 @@ class CircuitSerializer(NetBoxModelSerializer):
         ]
         ]
 
 
 
 
-class CircuitTerminationSerializer(ValidatedModelSerializer, LinkTerminationSerializer):
+class CircuitTerminationSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
     circuit = NestedCircuitSerializer()
     circuit = NestedCircuitSerializer()
     site = NestedSiteSerializer(required=False, allow_null=True)
     site = NestedSiteSerializer(required=False, allow_null=True)
@@ -110,5 +110,5 @@ class CircuitTerminationSerializer(ValidatedModelSerializer, LinkTerminationSeri
         fields = [
         fields = [
             'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed',
             'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed',
             'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'link_peers', 'link_peers_type',
             'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'link_peers', 'link_peers_type',
-            '_occupied', 'created', 'last_updated',
+            '_occupied', 'tags', 'custom_fields', 'created', 'last_updated',
         ]
         ]

+ 1 - 1
netbox/circuits/filtersets.py

@@ -198,7 +198,7 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
         ).distinct()
         ).distinct()
 
 
 
 
-class CircuitTerminationFilterSet(ChangeLoggedModelFilterSet, CabledObjectFilterSet):
+class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',

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

@@ -116,7 +116,7 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
         }
         }
 
 
 
 
-class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
+class CircuitTerminationForm(NetBoxModelForm):
     provider = DynamicModelChoiceField(
     provider = DynamicModelChoiceField(
         queryset=Provider.objects.all(),
         queryset=Provider.objects.all(),
         required=False,
         required=False,
@@ -161,7 +161,7 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
         model = CircuitTermination
         model = CircuitTermination
         fields = [
         fields = [
             'provider', 'circuit', 'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected',
             'provider', 'circuit', 'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected',
-            'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description',
+            'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'tags',
         ]
         ]
         help_texts = {
         help_texts = {
             'port_speed': "Physical circuit speed",
             'port_speed': "Physical circuit speed",

+ 2 - 1
netbox/circuits/graphql/types.py

@@ -1,4 +1,5 @@
 from circuits import filtersets, models
 from circuits import filtersets, models
+from extras.graphql.mixins import CustomFieldsMixin, TagsMixin
 from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType
 from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType
 
 
 __all__ = (
 __all__ = (
@@ -10,7 +11,7 @@ __all__ = (
 )
 )
 
 
 
 
-class CircuitTerminationType(ObjectType):
+class CircuitTerminationType(CustomFieldsMixin, TagsMixin, ObjectType):
 
 
     class Meta:
     class Meta:
         model = models.CircuitTermination
         model = models.CircuitTermination

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

@@ -1,18 +0,0 @@
-# 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),
-        ),
-    ]

+ 28 - 0
netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py

@@ -0,0 +1,28 @@
+import django.core.serializers.json
+from django.db import migrations, models
+import taggit.managers
+
+
+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),
+        ),
+        migrations.AddField(
+            model_name='circuittermination',
+            name='custom_field_data',
+            field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
+        ),
+        migrations.AddField(
+            model_name='circuittermination',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+    ]

+ 1 - 1
netbox/circuits/migrations/0037_new_cabling_models.py

@@ -4,7 +4,7 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('circuits', '0036_circuit_termination_date'),
+        ('circuits', '0036_circuit_termination_date_tags_custom_fields'),
     ]
     ]
 
 
     operations = [
     operations = [

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

@@ -5,7 +5,9 @@ from django.urls import reverse
 
 
 from circuits.choices import *
 from circuits.choices import *
 from dcim.models import LinkTermination
 from dcim.models import LinkTermination
-from netbox.models import ChangeLoggedModel, OrganizationalModel, NetBoxModel
+from netbox.models import (
+    ChangeLoggedModel, CustomFieldsMixin, CustomLinksMixin, OrganizationalModel, NetBoxModel, TagsMixin,
+)
 from netbox.models.features import WebhooksMixin
 from netbox.models.features import WebhooksMixin
 
 
 __all__ = (
 __all__ = (
@@ -141,7 +143,14 @@ class Circuit(NetBoxModel):
         return CircuitStatusChoices.colors.get(self.status)
         return CircuitStatusChoices.colors.get(self.status)
 
 
 
 
-class CircuitTermination(WebhooksMixin, ChangeLoggedModel, LinkTermination):
+class CircuitTermination(
+    CustomFieldsMixin,
+    CustomLinksMixin,
+    TagsMixin,
+    WebhooksMixin,
+    ChangeLoggedModel,
+    LinkTermination
+):
     circuit = models.ForeignKey(
     circuit = models.ForeignKey(
         to='circuits.Circuit',
         to='circuits.Circuit',
         on_delete=models.CASCADE,
         on_delete=models.CASCADE,

+ 9 - 0
netbox/dcim/api/nested_serializers.py

@@ -5,6 +5,7 @@ from netbox.api.serializers import BaseModelSerializer, WritableNestedSerializer
 
 
 __all__ = [
 __all__ = [
     'ComponentNestedModuleSerializer',
     'ComponentNestedModuleSerializer',
+    'ModuleBayNestedModuleSerializer',
     'NestedCableSerializer',
     'NestedCableSerializer',
     'NestedConsolePortSerializer',
     'NestedConsolePortSerializer',
     'NestedConsolePortTemplateSerializer',
     'NestedConsolePortTemplateSerializer',
@@ -281,6 +282,14 @@ class ModuleNestedModuleBaySerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'display', 'name']
         fields = ['id', 'url', 'display', 'name']
 
 
 
 
+class ModuleBayNestedModuleSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail')
+
+    class Meta:
+        model = models.Module
+        fields = ['id', 'url', 'display', 'serial']
+
+
 class ComponentNestedModuleSerializer(WritableNestedSerializer):
 class ComponentNestedModuleSerializer(WritableNestedSerializer):
     """
     """
     Used by device component serializers.
     Used by device component serializers.

+ 8 - 5
netbox/dcim/api/serializers.py

@@ -10,7 +10,8 @@ from dcim.constants import *
 from dcim.models import *
 from dcim.models import *
 from extras.api.serializers import ContentTypeSerializer
 from extras.api.serializers import ContentTypeSerializer
 from ipam.api.nested_serializers import (
 from ipam.api.nested_serializers import (
-    NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer,
+    NestedASNSerializer, NestedIPAddressSerializer, NestedL2VPNTerminationSerializer, NestedVLANSerializer,
+    NestedVRFSerializer,
 )
 )
 from ipam.models import ASN, VLAN
 from ipam.models import ASN, VLAN
 from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
@@ -868,6 +869,7 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
         many=True
         many=True
     )
     )
     vrf = NestedVRFSerializer(required=False, allow_null=True)
     vrf = NestedVRFSerializer(required=False, allow_null=True)
+    l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True)
     cable = NestedCableSerializer(read_only=True)
     cable = NestedCableSerializer(read_only=True)
     wireless_link = NestedWirelessLinkSerializer(read_only=True)
     wireless_link = NestedWirelessLinkSerializer(read_only=True)
     wireless_lans = SerializedPKRelatedField(
     wireless_lans = SerializedPKRelatedField(
@@ -886,8 +888,9 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
             'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel',
             'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel',
             'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan',
             'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan',
             'tagged_vlans', 'mark_connected', 'cable', 'wireless_link', 'link_peers', 'link_peers_type',
             'tagged_vlans', 'mark_connected', 'cable', 'wireless_link', 'link_peers', 'link_peers_type',
-            'wireless_lans', 'vrf', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable',
-            'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
+            'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints', 'connected_endpoints_type',
+            'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses',
+            'count_fhrp_groups', '_occupied',
         ]
         ]
 
 
     def validate(self, data):
     def validate(self, data):
@@ -957,12 +960,12 @@ class FrontPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
 class ModuleBaySerializer(NetBoxModelSerializer):
 class ModuleBaySerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail')
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
-    # installed_module = NestedModuleSerializer(required=False, allow_null=True)
+    installed_module = ModuleBayNestedModuleSerializer(required=False, allow_null=True)
 
 
     class Meta:
     class Meta:
         model = ModuleBay
         model = ModuleBay
         fields = [
         fields = [
-            'id', 'url', 'display', 'device', 'name', 'label', 'position', 'description', 'tags', 'custom_fields',
+            'id', 'url', 'display', 'device', 'name', 'installed_module', 'label', 'position', 'description', 'tags', 'custom_fields',
             'created', 'last_updated',
             'created', 'last_updated',
         ]
         ]
 
 

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

@@ -621,7 +621,7 @@ class RearPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
 
 
 
 
 class ModuleBayViewSet(NetBoxModelViewSet):
 class ModuleBayViewSet(NetBoxModelViewSet):
-    queryset = ModuleBay.objects.prefetch_related('tags')
+    queryset = ModuleBay.objects.prefetch_related('tags', 'installed_module')
     serializer_class = serializers.ModuleBaySerializer
     serializer_class = serializers.ModuleBaySerializer
     filterset_class = filtersets.ModuleBayFilterSet
     filterset_class = filtersets.ModuleBayFilterSet
     brief_prefetch_fields = ['device']
     brief_prefetch_fields = ['device']

+ 0 - 9
netbox/dcim/constants.py

@@ -50,15 +50,6 @@ WIRELESS_IFACE_TYPES = [
 NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
 NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
 
 
 
 
-#
-# Power feeds
-#
-
-POWERFEED_VOLTAGE_DEFAULT = 120
-POWERFEED_AMPERAGE_DEFAULT = 20
-POWERFEED_MAX_UTILIZATION_DEFAULT = 80  # Percentage
-
-
 #
 #
 # Device components
 # Device components
 #
 #

+ 6 - 0
netbox/dcim/filtersets.py

@@ -997,6 +997,12 @@ class ModuleFilterSet(NetBoxModelFilterSet):
         to_field_name='model',
         to_field_name='model',
         label='Module type (model)',
         label='Module type (model)',
     )
     )
+    module_bay_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='module_bay',
+        queryset=ModuleBay.objects.all(),
+        to_field_name='id',
+        label='Module Bay (ID)'
+    )
     device_id = django_filters.ModelMultipleChoiceFilter(
     device_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         label='Device (ID)',
         label='Device (ID)',

+ 16 - 1
netbox/dcim/forms/models.py

@@ -525,13 +525,28 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
         required=False,
         required=False,
         label=''
         label=''
     )
     )
+    virtual_chassis = DynamicModelChoiceField(
+        queryset=VirtualChassis.objects.all(),
+        required=False
+    )
+    vc_position = forms.IntegerField(
+        required=False,
+        label='Position',
+        help_text="The position in the virtual chassis this device is identified by"
+    )
+    vc_priority = forms.IntegerField(
+        required=False,
+        label='Priority',
+        help_text="The priority of the device in the virtual chassis"
+    )
 
 
     class Meta:
     class Meta:
         model = Device
         model = Device
         fields = [
         fields = [
             'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'rack',
             'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'rack',
             'location', 'position', 'face', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6',
             'location', 'position', 'face', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6',
-            'cluster_group', 'cluster', 'tenant_group', 'tenant', 'comments', 'tags', 'local_context_data'
+            'cluster_group', 'cluster', 'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority',
+            'comments', 'tags', 'local_context_data'
         ]
         ]
         help_texts = {
         help_texts = {
             'device_role': "The function this device serves",
             'device_role': "The function this device serves",

+ 3 - 3
netbox/dcim/migrations/0001_squashed.py

@@ -386,9 +386,9 @@ class Migration(migrations.Migration):
                 ('type', models.CharField(default='primary', max_length=50)),
                 ('type', models.CharField(default='primary', max_length=50)),
                 ('supply', models.CharField(default='ac', max_length=50)),
                 ('supply', models.CharField(default='ac', max_length=50)),
                 ('phase', models.CharField(default='single-phase', max_length=50)),
                 ('phase', models.CharField(default='single-phase', max_length=50)),
-                ('voltage', models.SmallIntegerField(default=120, validators=[utilities.validators.ExclusionValidator([0])])),
-                ('amperage', models.PositiveSmallIntegerField(default=20, validators=[django.core.validators.MinValueValidator(1)])),
-                ('max_utilization', models.PositiveSmallIntegerField(default=80, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)])),
+                ('voltage', models.SmallIntegerField(validators=[utilities.validators.ExclusionValidator([0])])),
+                ('amperage', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1)])),
+                ('max_utilization', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)])),
                 ('available_power', models.PositiveIntegerField(default=0, editable=False)),
                 ('available_power', models.PositiveIntegerField(default=0, editable=False)),
                 ('comments', models.TextField(blank=True)),
                 ('comments', models.TextField(blank=True)),
             ],
             ],

+ 10 - 0
netbox/dcim/models/device_components.py

@@ -648,6 +648,12 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
         object_id_field='interface_id',
         object_id_field='interface_id',
         related_query_name='+'
         related_query_name='+'
     )
     )
+    l2vpn_terminations = GenericRelation(
+        to='ipam.L2VPNTermination',
+        content_type_field='assigned_object_type',
+        object_id_field='assigned_object_id',
+        related_query_name='interface',
+    )
 
 
     clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only', 'poe_mode', 'poe_type']
     clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only', 'poe_mode', 'poe_type']
 
 
@@ -822,6 +828,10 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
     def link(self):
     def link(self):
         return self.cable or self.wireless_link
         return self.cable or self.wireless_link
 
 
+    @property
+    def l2vpn_termination(self):
+        return self.l2vpn_terminations.first()
+
 
 
 #
 #
 # Pass-through ports
 # Pass-through ports

+ 4 - 3
netbox/dcim/models/power.py

@@ -6,6 +6,7 @@ from django.urls import reverse
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
+from netbox.config import ConfigItem
 from netbox.models import NetBoxModel
 from netbox.models import NetBoxModel
 from utilities.validators import ExclusionValidator
 from utilities.validators import ExclusionValidator
 from .device_components import LinkTermination, PathEndpoint
 from .device_components import LinkTermination, PathEndpoint
@@ -105,16 +106,16 @@ class PowerFeed(NetBoxModel, PathEndpoint, LinkTermination):
         default=PowerFeedPhaseChoices.PHASE_SINGLE
         default=PowerFeedPhaseChoices.PHASE_SINGLE
     )
     )
     voltage = models.SmallIntegerField(
     voltage = models.SmallIntegerField(
-        default=POWERFEED_VOLTAGE_DEFAULT,
+        default=ConfigItem('POWERFEED_DEFAULT_VOLTAGE'),
         validators=[ExclusionValidator([0])]
         validators=[ExclusionValidator([0])]
     )
     )
     amperage = models.PositiveSmallIntegerField(
     amperage = models.PositiveSmallIntegerField(
         validators=[MinValueValidator(1)],
         validators=[MinValueValidator(1)],
-        default=POWERFEED_AMPERAGE_DEFAULT
+        default=ConfigItem('POWERFEED_DEFAULT_AMPERAGE')
     )
     )
     max_utilization = models.PositiveSmallIntegerField(
     max_utilization = models.PositiveSmallIntegerField(
         validators=[MinValueValidator(1), MaxValueValidator(100)],
         validators=[MinValueValidator(1), MaxValueValidator(100)],
-        default=POWERFEED_MAX_UTILIZATION_DEFAULT,
+        default=ConfigItem('POWERFEED_DEFAULT_MAX_UTILIZATION'),
         help_text="Maximum permissible draw (percentage)"
         help_text="Maximum permissible draw (percentage)"
     )
     )
     available_power = models.PositiveIntegerField(
     available_power = models.PositiveIntegerField(

+ 5 - 0
netbox/dcim/tests/test_filtersets.py

@@ -1853,6 +1853,11 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'module_type': [module_types[0].model, module_types[1].model]}
         params = {'module_type': [module_types[0].model, module_types[1].model]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
 
 
+    def test_module_bay(self):
+        module_bays = ModuleBay.objects.all()[:2]
+        params = {'module_bay_id': [module_bays[0].pk, module_bays[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_device(self):
     def test_device(self):
         device_types = Device.objects.all()[:2]
         device_types = Device.objects.all()[:2]
         params = {'device_id': [device_types[0].pk, device_types[1].pk]}
         params = {'device_id': [device_types[0].pk, device_types[1].pk]}

+ 3 - 0
netbox/extras/admin.py

@@ -15,6 +15,9 @@ class ConfigRevisionAdmin(admin.ModelAdmin):
         ('Rack Elevations', {
         ('Rack Elevations', {
             'fields': ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH'),
             'fields': ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH'),
         }),
         }),
+        ('Power', {
+            'fields': ('POWERFEED_DEFAULT_VOLTAGE', 'POWERFEED_DEFAULT_AMPERAGE', 'POWERFEED_DEFAULT_MAX_UTILIZATION')
+        }),
         ('IPAM', {
         ('IPAM', {
             'fields': ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4'),
             'fields': ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4'),
         }),
         }),

+ 10 - 3
netbox/extras/webhooks.py

@@ -1,6 +1,5 @@
 import hashlib
 import hashlib
 import hmac
 import hmac
-from collections import defaultdict
 
 
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.utils import timezone
 from django.utils import timezone
@@ -27,10 +26,18 @@ def serialize_for_webhook(instance):
 
 
 
 
 def get_snapshots(instance, action):
 def get_snapshots(instance, action):
-    return {
+    snapshots = {
         'prechange': getattr(instance, '_prechange_snapshot', None),
         'prechange': getattr(instance, '_prechange_snapshot', None),
-        'postchange': serialize_object(instance) if action != ObjectChangeActionChoices.ACTION_DELETE else None,
+        'postchange': None,
     }
     }
+    if action != ObjectChangeActionChoices.ACTION_DELETE:
+        # Use model's serialize() method if defined; fall back to serialize_object
+        if hasattr(instance, 'serialize_object'):
+            snapshots['postchange'] = instance.serialize_object()
+        else:
+            snapshots['postchange'] = serialize_object(instance)
+
+    return snapshots
 
 
 
 
 def generate_signature(request_body, secret):
 def generate_signature(request_body, secret):

+ 28 - 0
netbox/ipam/api/nested_serializers.py

@@ -1,6 +1,7 @@
 from rest_framework import serializers
 from rest_framework import serializers
 
 
 from ipam import models
 from ipam import models
+from ipam.models.l2vpn import L2VPNTermination, L2VPN
 from netbox.api import WritableNestedSerializer
 from netbox.api import WritableNestedSerializer
 
 
 __all__ = [
 __all__ = [
@@ -10,6 +11,8 @@ __all__ = [
     'NestedFHRPGroupAssignmentSerializer',
     'NestedFHRPGroupAssignmentSerializer',
     'NestedIPAddressSerializer',
     'NestedIPAddressSerializer',
     'NestedIPRangeSerializer',
     'NestedIPRangeSerializer',
+    'NestedL2VPNSerializer',
+    'NestedL2VPNTerminationSerializer',
     'NestedPrefixSerializer',
     'NestedPrefixSerializer',
     'NestedRIRSerializer',
     'NestedRIRSerializer',
     'NestedRoleSerializer',
     'NestedRoleSerializer',
@@ -190,3 +193,28 @@ class NestedServiceSerializer(WritableNestedSerializer):
     class Meta:
     class Meta:
         model = models.Service
         model = models.Service
         fields = ['id', 'url', 'display', 'name', 'protocol', 'ports']
         fields = ['id', 'url', 'display', 'name', 'protocol', 'ports']
+
+#
+# L2VPN
+#
+
+
+class NestedL2VPNSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:l2vpn-detail')
+
+    class Meta:
+        model = L2VPN
+        fields = [
+            'id', 'url', 'display', 'identifier', 'name', 'slug', 'type'
+        ]
+
+
+class NestedL2VPNTerminationSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:l2vpntermination-detail')
+    l2vpn = NestedL2VPNSerializer()
+
+    class Meta:
+        model = L2VPNTermination
+        fields = [
+            'id', 'url', 'display', 'l2vpn'
+        ]

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

@@ -19,6 +19,9 @@ from .nested_serializers import *
 #
 #
 # ASNs
 # ASNs
 #
 #
+from .nested_serializers import NestedL2VPNSerializer
+from ..models.l2vpn import L2VPNTermination, L2VPN
+
 
 
 class ASNSerializer(NetBoxModelSerializer):
 class ASNSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail')
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail')
@@ -204,13 +207,14 @@ class VLANSerializer(NetBoxModelSerializer):
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     status = ChoiceField(choices=VLANStatusChoices, required=False)
     status = ChoiceField(choices=VLANStatusChoices, required=False)
     role = NestedRoleSerializer(required=False, allow_null=True)
     role = NestedRoleSerializer(required=False, allow_null=True)
+    l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True)
     prefix_count = serializers.IntegerField(read_only=True)
     prefix_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = VLAN
         model = VLAN
         fields = [
         fields = [
-            'id', 'url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'tags',
-            'custom_fields', 'created', 'last_updated', 'prefix_count',
+            'id', 'url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description',
+            'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated', 'prefix_count',
         ]
         ]
 
 
 
 
@@ -433,3 +437,54 @@ class ServiceSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'device', 'virtual_machine', 'name', 'ports', 'protocol', 'ipaddresses',
             'id', 'url', 'display', 'device', 'virtual_machine', 'name', 'ports', 'protocol', 'ipaddresses',
             'description', 'tags', 'custom_fields', 'created', 'last_updated',
             'description', 'tags', 'custom_fields', 'created', 'last_updated',
         ]
         ]
+
+#
+# L2VPN
+#
+
+
+class L2VPNSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:l2vpn-detail')
+    type = ChoiceField(choices=L2VPNTypeChoices, required=False)
+    import_targets = SerializedPKRelatedField(
+        queryset=RouteTarget.objects.all(),
+        serializer=NestedRouteTargetSerializer,
+        required=False,
+        many=True
+    )
+    export_targets = SerializedPKRelatedField(
+        queryset=RouteTarget.objects.all(),
+        serializer=NestedRouteTargetSerializer,
+        required=False,
+        many=True
+    )
+    tenant = NestedTenantSerializer(required=False, allow_null=True)
+
+    class Meta:
+        model = L2VPN
+        fields = [
+            'id', 'url', 'display', 'identifier', 'name', 'slug', 'type', 'import_targets', 'export_targets',
+            'description', 'tenant', 'tags', 'custom_fields', 'created', 'last_updated'
+        ]
+
+
+class L2VPNTerminationSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:l2vpntermination-detail')
+    l2vpn = NestedL2VPNSerializer()
+    assigned_object_type = ContentTypeField(
+        queryset=ContentType.objects.all()
+    )
+    assigned_object = serializers.SerializerMethodField(read_only=True)
+
+    class Meta:
+        model = L2VPNTermination
+        fields = [
+            'id', 'url', 'display', 'l2vpn', 'assigned_object_type', 'assigned_object_id',
+            'assigned_object', 'tags', 'custom_fields', 'created', 'last_updated'
+        ]
+
+    @swagger_serializer_method(serializer_or_field=serializers.DictField)
+    def get_assigned_object(self, instance):
+        serializer = get_serializer_for_model(instance.assigned_object, prefix='Nested')
+        context = {'request': self.context['request']}
+        return serializer(instance.assigned_object, context=context).data

+ 4 - 0
netbox/ipam/api/urls.py

@@ -45,6 +45,10 @@ router.register('vlans', views.VLANViewSet)
 router.register('service-templates', views.ServiceTemplateViewSet)
 router.register('service-templates', views.ServiceTemplateViewSet)
 router.register('services', views.ServiceViewSet)
 router.register('services', views.ServiceViewSet)
 
 
+# L2VPN
+router.register('l2vpns', views.L2VPNViewSet)
+router.register('l2vpn-terminations', views.L2VPNTerminationViewSet)
+
 app_name = 'ipam-api'
 app_name = 'ipam-api'
 
 
 urlpatterns = [
 urlpatterns = [

+ 13 - 0
netbox/ipam/api/views.py

@@ -18,6 +18,7 @@ from netbox.config import get_config
 from utilities.constants import ADVISORY_LOCK_KEYS
 from utilities.constants import ADVISORY_LOCK_KEYS
 from utilities.utils import count_related
 from utilities.utils import count_related
 from . import serializers
 from . import serializers
+from ipam.models import L2VPN, L2VPNTermination
 
 
 
 
 class IPAMRootView(APIRootView):
 class IPAMRootView(APIRootView):
@@ -157,6 +158,18 @@ class ServiceViewSet(NetBoxModelViewSet):
     filterset_class = filtersets.ServiceFilterSet
     filterset_class = filtersets.ServiceFilterSet
 
 
 
 
+class L2VPNViewSet(NetBoxModelViewSet):
+    queryset = L2VPN.objects.prefetch_related('import_targets', 'export_targets', 'tenant', 'tags')
+    serializer_class = serializers.L2VPNSerializer
+    filterset_class = filtersets.L2VPNFilterSet
+
+
+class L2VPNTerminationViewSet(NetBoxModelViewSet):
+    queryset = L2VPNTermination.objects.prefetch_related('assigned_object')
+    serializer_class = serializers.L2VPNTerminationSerializer
+    filterset_class = filtersets.L2VPNTerminationFilterSet
+
+
 #
 #
 # Views
 # Views
 #
 #

+ 49 - 0
netbox/ipam/choices.py

@@ -170,3 +170,52 @@ class ServiceProtocolChoices(ChoiceSet):
         (PROTOCOL_UDP, 'UDP'),
         (PROTOCOL_UDP, 'UDP'),
         (PROTOCOL_SCTP, 'SCTP'),
         (PROTOCOL_SCTP, 'SCTP'),
     )
     )
+
+
+class L2VPNTypeChoices(ChoiceSet):
+    TYPE_VPLS = 'vpls'
+    TYPE_VPWS = 'vpws'
+    TYPE_EPL = 'epl'
+    TYPE_EVPL = 'evpl'
+    TYPE_EPLAN = 'ep-lan'
+    TYPE_EVPLAN = 'evp-lan'
+    TYPE_EPTREE = 'ep-tree'
+    TYPE_EVPTREE = 'evp-tree'
+    TYPE_VXLAN = 'vxlan'
+    TYPE_VXLAN_EVPN = 'vxlan-evpn'
+    TYPE_MPLS_EVPN = 'mpls-evpn'
+    TYPE_PBB_EVPN = 'pbb-evpn'
+
+    CHOICES = (
+        ('VPLS', (
+            (TYPE_VPWS, 'VPWS'),
+            (TYPE_VPLS, 'VPLS'),
+        )),
+        ('VXLAN', (
+            (TYPE_VXLAN, 'VXLAN'),
+            (TYPE_VXLAN_EVPN, 'VXLAN-EVPN'),
+        )),
+        ('L2VPN E-VPN', (
+            (TYPE_MPLS_EVPN, 'MPLS EVPN'),
+            (TYPE_PBB_EVPN, 'PBB EVPN'),
+        )),
+        ('E-Line', (
+            (TYPE_EPL, 'EPL'),
+            (TYPE_EVPL, 'EVPL'),
+        )),
+        ('E-LAN', (
+            (TYPE_EPLAN, 'Ethernet Private LAN'),
+            (TYPE_EVPLAN, 'Ethernet Virtual Private LAN'),
+        )),
+        ('E-Tree', (
+            (TYPE_EPTREE, 'Ethernet Private Tree'),
+            (TYPE_EVPTREE, 'Ethernet Virtual Private Tree'),
+        )),
+    )
+
+    P2P = (
+        TYPE_VPWS,
+        TYPE_EPL,
+        TYPE_EPLAN,
+        TYPE_EPTREE
+    )

+ 6 - 0
netbox/ipam/constants.py

@@ -90,3 +90,9 @@ VLANGROUP_SCOPE_TYPES = (
 # 16-bit port number
 # 16-bit port number
 SERVICE_PORT_MIN = 1
 SERVICE_PORT_MIN = 1
 SERVICE_PORT_MAX = 65535
 SERVICE_PORT_MAX = 65535
+
+L2VPN_ASSIGNMENT_MODELS = Q(
+    Q(app_label='dcim', model='interface') |
+    Q(app_label='ipam', model='vlan') |
+    Q(app_label='virtualization', model='vminterface')
+)

+ 112 - 0
netbox/ipam/filtersets.py

@@ -23,6 +23,8 @@ __all__ = (
     'FHRPGroupFilterSet',
     'FHRPGroupFilterSet',
     'IPAddressFilterSet',
     'IPAddressFilterSet',
     'IPRangeFilterSet',
     'IPRangeFilterSet',
+    'L2VPNFilterSet',
+    'L2VPNTerminationFilterSet',
     'PrefixFilterSet',
     'PrefixFilterSet',
     'RIRFilterSet',
     'RIRFilterSet',
     'RoleFilterSet',
     'RoleFilterSet',
@@ -922,3 +924,113 @@ class ServiceFilterSet(NetBoxModelFilterSet):
             return queryset
             return queryset
         qs_filter = Q(name__icontains=value) | Q(description__icontains=value)
         qs_filter = Q(name__icontains=value) | Q(description__icontains=value)
         return queryset.filter(qs_filter)
         return queryset.filter(qs_filter)
+
+
+#
+# L2VPN
+#
+
+
+class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
+    import_target_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='import_targets',
+        queryset=RouteTarget.objects.all(),
+        label='Import target',
+    )
+    import_target = django_filters.ModelMultipleChoiceFilter(
+        field_name='import_targets__name',
+        queryset=RouteTarget.objects.all(),
+        to_field_name='name',
+        label='Import target (name)',
+    )
+    export_target_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='export_targets',
+        queryset=RouteTarget.objects.all(),
+        label='Export target',
+    )
+    export_target = django_filters.ModelMultipleChoiceFilter(
+        field_name='export_targets__name',
+        queryset=RouteTarget.objects.all(),
+        to_field_name='name',
+        label='Export target (name)',
+    )
+
+    class Meta:
+        model = L2VPN
+        fields = ['id', 'identifier', 'name', 'type', 'description']
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        qs_filter = Q(identifier=value) | Q(name__icontains=value) | Q(description__icontains=value)
+        return queryset.filter(qs_filter)
+
+
+class L2VPNTerminationFilterSet(NetBoxModelFilterSet):
+    l2vpn_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=L2VPN.objects.all(),
+        label='L2VPN (ID)',
+    )
+    l2vpn = django_filters.ModelMultipleChoiceFilter(
+        field_name='l2vpn__name',
+        queryset=L2VPN.objects.all(),
+        to_field_name='name',
+        label='L2VPN (name)',
+    )
+    device = MultiValueCharFilter(
+        method='filter_device',
+        field_name='name',
+        label='Device (name)',
+    )
+    device_id = MultiValueNumberFilter(
+        method='filter_device',
+        field_name='pk',
+        label='Device (ID)',
+    )
+    interface = django_filters.ModelMultipleChoiceFilter(
+        field_name='interface__name',
+        queryset=Interface.objects.all(),
+        to_field_name='name',
+        label='Interface (name)',
+    )
+    interface_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='interface',
+        queryset=Interface.objects.all(),
+        label='Interface (ID)',
+    )
+    vlan = django_filters.ModelMultipleChoiceFilter(
+        field_name='vlan__name',
+        queryset=VLAN.objects.all(),
+        to_field_name='name',
+        label='VLAN (name)',
+    )
+    vlan_vid = django_filters.NumberFilter(
+        field_name='vlan__vid',
+        label='VLAN number (1-4094)',
+    )
+    vlan_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='vlan',
+        queryset=VLAN.objects.all(),
+        label='VLAN (ID)',
+    )
+
+    class Meta:
+        model = L2VPNTermination
+        fields = ['id', ]
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        qs_filter = Q(l2vpn__name__icontains=value)
+        return queryset.filter(qs_filter)
+
+    def filter_device(self, queryset, name, value):
+        devices = Device.objects.filter(**{'{}__in'.format(name): value})
+        if not devices.exists():
+            return queryset.none()
+        interface_ids = []
+        for device in devices:
+            interface_ids.extend(device.vc_interfaces().values_list('id', flat=True))
+        return queryset.filter(
+            interface__in=interface_ids
+        )

+ 23 - 0
netbox/ipam/forms/bulk_edit.py

@@ -18,6 +18,8 @@ __all__ = (
     'FHRPGroupBulkEditForm',
     'FHRPGroupBulkEditForm',
     'IPAddressBulkEditForm',
     'IPAddressBulkEditForm',
     'IPRangeBulkEditForm',
     'IPRangeBulkEditForm',
+    'L2VPNBulkEditForm',
+    'L2VPNTerminationBulkEditForm',
     'PrefixBulkEditForm',
     'PrefixBulkEditForm',
     'RIRBulkEditForm',
     'RIRBulkEditForm',
     'RoleBulkEditForm',
     'RoleBulkEditForm',
@@ -440,3 +442,24 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm):
 
 
 class ServiceBulkEditForm(ServiceTemplateBulkEditForm):
 class ServiceBulkEditForm(ServiceTemplateBulkEditForm):
     model = Service
     model = Service
+
+
+class L2VPNBulkEditForm(NetBoxModelBulkEditForm):
+    tenant = DynamicModelChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False
+    )
+    description = forms.CharField(
+        max_length=100,
+        required=False
+    )
+
+    model = L2VPN
+    fieldsets = (
+        (None, ('tenant', 'description')),
+    )
+    nullable_fields = ('tenant', 'description',)
+
+
+class L2VPNTerminationBulkEditForm(NetBoxModelBulkEditForm):
+    model = L2VPN

+ 83 - 0
netbox/ipam/forms/bulk_import.py

@@ -1,5 +1,6 @@
 from django import forms
 from django import forms
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ValidationError
 
 
 from dcim.models import Device, Interface, Site
 from dcim.models import Device, Interface, Site
 from ipam.choices import *
 from ipam.choices import *
@@ -16,6 +17,8 @@ __all__ = (
     'FHRPGroupCSVForm',
     'FHRPGroupCSVForm',
     'IPAddressCSVForm',
     'IPAddressCSVForm',
     'IPRangeCSVForm',
     'IPRangeCSVForm',
+    'L2VPNCSVForm',
+    'L2VPNTerminationCSVForm',
     'PrefixCSVForm',
     'PrefixCSVForm',
     'RIRCSVForm',
     'RIRCSVForm',
     'RoleCSVForm',
     'RoleCSVForm',
@@ -425,3 +428,83 @@ class ServiceCSVForm(NetBoxModelCSVForm):
     class Meta:
     class Meta:
         model = Service
         model = Service
         fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description')
         fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description')
+
+
+class L2VPNCSVForm(NetBoxModelCSVForm):
+    tenant = CSVModelChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False,
+        to_field_name='name',
+    )
+    type = CSVChoiceField(
+        choices=L2VPNTypeChoices,
+        help_text='IP protocol'
+    )
+
+    class Meta:
+        model = L2VPN
+        fields = ('identifier', 'name', 'slug', 'type', 'description')
+
+
+class L2VPNTerminationCSVForm(NetBoxModelCSVForm):
+    l2vpn = CSVModelChoiceField(
+        queryset=L2VPN.objects.all(),
+        required=True,
+        to_field_name='name',
+        label='L2VPN',
+    )
+    device = CSVModelChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Parent device (for interface)'
+    )
+    virtual_machine = CSVModelChoiceField(
+        queryset=VirtualMachine.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Parent virtual machine (for interface)'
+    )
+    interface = CSVModelChoiceField(
+        queryset=Interface.objects.none(),  # Can also refer to VMInterface
+        required=False,
+        to_field_name='name',
+        help_text='Assigned interface (device or VM)'
+    )
+    vlan = CSVModelChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Assigned VLAN'
+    )
+
+    class Meta:
+        model = L2VPNTermination
+        fields = ('l2vpn', 'device', 'virtual_machine', 'interface', 'vlan')
+
+    def __init__(self, data=None, *args, **kwargs):
+        super().__init__(data, *args, **kwargs)
+
+        if data:
+
+            # Limit interface queryset by device or VM
+            if data.get('device'):
+                self.fields['interface'].queryset = Interface.objects.filter(
+                    **{f"device__{self.fields['device'].to_field_name}": data['device']}
+                )
+            elif data.get('virtual_machine'):
+                self.fields['interface'].queryset = VMInterface.objects.filter(
+                    **{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']}
+                )
+
+    def clean(self):
+        super().clean()
+
+        if self.cleaned_data.get('device') and self.cleaned_data.get('virtual_machine'):
+            raise ValidationError('Cannot import device and VM interface terminations simultaneously.')
+        if not (self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')):
+            raise ValidationError('Each termination must specify either an interface or a VLAN.')
+        if self.cleaned_data.get('interface') and self.cleaned_data.get('vlan'):
+            raise ValidationError('Cannot assign both an interface and a VLAN.')
+
+        self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')

+ 42 - 1
netbox/ipam/forms/filtersets.py

@@ -1,7 +1,8 @@
 from django import forms
 from django import forms
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
-from dcim.models import Location, Rack, Region, Site, SiteGroup
+from dcim.models import Location, Rack, Region, Site, SiteGroup, Device
+from virtualization.models import VirtualMachine
 from ipam.choices import *
 from ipam.choices import *
 from ipam.constants import *
 from ipam.constants import *
 from ipam.models import *
 from ipam.models import *
@@ -19,6 +20,8 @@ __all__ = (
     'FHRPGroupFilterForm',
     'FHRPGroupFilterForm',
     'IPAddressFilterForm',
     'IPAddressFilterForm',
     'IPRangeFilterForm',
     'IPRangeFilterForm',
+    'L2VPNFilterForm',
+    'L2VPNTerminationFilterForm',
     'PrefixFilterForm',
     'PrefixFilterForm',
     'RIRFilterForm',
     'RIRFilterForm',
     'RoleFilterForm',
     'RoleFilterForm',
@@ -265,6 +268,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
         ('Attributes', ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface')),
         ('Attributes', ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface')),
         ('VRF', ('vrf_id', 'present_in_vrf_id')),
         ('VRF', ('vrf_id', 'present_in_vrf_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
+        ('Device/VM', ('device_id', 'virtual_machine_id')),
     )
     )
     parent = forms.CharField(
     parent = forms.CharField(
         required=False,
         required=False,
@@ -298,6 +302,16 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
         required=False,
         required=False,
         label=_('Present in VRF')
         label=_('Present in VRF')
     )
     )
+    device_id = DynamicModelMultipleChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        label=_('Assigned Device'),
+    )
+    virtual_machine_id = DynamicModelMultipleChoiceField(
+        queryset=VirtualMachine.objects.all(),
+        required=False,
+        label=_('Assigned VM'),
+    )
     status = MultipleChoiceField(
     status = MultipleChoiceField(
         choices=IPAddressStatusChoices,
         choices=IPAddressStatusChoices,
         required=False
         required=False
@@ -463,3 +477,30 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm):
 
 
 class ServiceFilterForm(ServiceTemplateFilterForm):
 class ServiceFilterForm(ServiceTemplateFilterForm):
     model = Service
     model = Service
+
+
+class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
+    model = L2VPN
+    fieldsets = (
+        (None, ('type', )),
+        ('Tenant', ('tenant_group_id', 'tenant_id')),
+    )
+    type = forms.ChoiceField(
+        choices=add_blank_choice(L2VPNTypeChoices),
+        required=False,
+        widget=StaticSelect()
+    )
+
+
+class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm):
+    model = L2VPNTermination
+    fieldsets = (
+        (None, ('l2vpn', )),
+    )
+    l2vpn = DynamicModelChoiceField(
+        queryset=L2VPN.objects.all(),
+        required=True,
+        query_params={},
+        label='L2VPN',
+        fetch_trigger='open'
+    )

+ 111 - 1
netbox/ipam/forms/models.py

@@ -1,5 +1,6 @@
 from django import forms
 from django import forms
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ValidationError
 
 
 from dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup
 from dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup
 from extras.models import Tag
 from extras.models import Tag
@@ -7,9 +8,9 @@ from ipam.choices import *
 from ipam.constants import *
 from ipam.constants import *
 from ipam.formfields import IPNetworkFormField
 from ipam.formfields import IPNetworkFormField
 from ipam.models import *
 from ipam.models import *
-from ipam.models import ASN
 from netbox.forms import NetBoxModelForm
 from netbox.forms import NetBoxModelForm
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
+from tenancy.models import Tenant
 from utilities.exceptions import PermissionsViolation
 from utilities.exceptions import PermissionsViolation
 from utilities.forms import (
 from utilities.forms import (
     add_blank_choice, BootstrapMixin, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField,
     add_blank_choice, BootstrapMixin, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField,
@@ -26,6 +27,8 @@ __all__ = (
     'IPAddressBulkAddForm',
     'IPAddressBulkAddForm',
     'IPAddressForm',
     'IPAddressForm',
     'IPRangeForm',
     'IPRangeForm',
+    'L2VPNForm',
+    'L2VPNTerminationForm',
     'PrefixForm',
     'PrefixForm',
     'RIRForm',
     'RIRForm',
     'RoleForm',
     'RoleForm',
@@ -861,3 +864,110 @@ class ServiceCreateForm(ServiceForm):
                 self.cleaned_data['description'] = service_template.description
                 self.cleaned_data['description'] = service_template.description
         elif not all(self.cleaned_data[f] for f in ('name', 'protocol', 'ports')):
         elif not all(self.cleaned_data[f] for f in ('name', 'protocol', 'ports')):
             raise forms.ValidationError("Must specify name, protocol, and port(s) if not using a service template.")
             raise forms.ValidationError("Must specify name, protocol, and port(s) if not using a service template.")
+
+
+#
+# L2VPN
+#
+
+
+class L2VPNForm(TenancyForm, NetBoxModelForm):
+    slug = SlugField()
+    import_targets = DynamicModelMultipleChoiceField(
+        queryset=RouteTarget.objects.all(),
+        required=False
+    )
+    export_targets = DynamicModelMultipleChoiceField(
+        queryset=RouteTarget.objects.all(),
+        required=False
+    )
+
+    fieldsets = (
+        ('L2VPN', ('name', 'slug', 'type', 'identifier', 'description', 'tags')),
+        ('Route Targets', ('import_targets', 'export_targets')),
+        ('Tenancy', ('tenant_group', 'tenant')),
+    )
+
+    class Meta:
+        model = L2VPN
+        fields = (
+            'name', 'slug', 'type', 'identifier', 'description', 'import_targets', 'export_targets', 'tenant', 'tags'
+        )
+        widgets = {
+            'type': StaticSelect(),
+        }
+
+
+class L2VPNTerminationForm(NetBoxModelForm):
+    l2vpn = DynamicModelChoiceField(
+        queryset=L2VPN.objects.all(),
+        required=True,
+        query_params={},
+        label='L2VPN',
+        fetch_trigger='open'
+    )
+    device = DynamicModelChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        query_params={}
+    )
+    vlan = DynamicModelChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False,
+        query_params={
+            'available_on_device': '$device'
+        }
+    )
+    interface = DynamicModelChoiceField(
+        queryset=Interface.objects.all(),
+        required=False,
+        query_params={
+            'device_id': '$device'
+        }
+    )
+    virtual_machine = DynamicModelChoiceField(
+        queryset=VirtualMachine.objects.all(),
+        required=False,
+        query_params={}
+    )
+    vminterface = DynamicModelChoiceField(
+        queryset=VMInterface.objects.all(),
+        required=False,
+        query_params={
+            'virtual_machine_id': '$virtual_machine'
+        }
+    )
+
+    class Meta:
+        model = L2VPNTermination
+        fields = ('l2vpn', )
+
+    def __init__(self, *args, **kwargs):
+        instance = kwargs.get('instance')
+        initial = kwargs.get('initial', {}).copy()
+
+        if instance:
+            if type(instance.assigned_object) is Interface:
+                initial['device'] = instance.assigned_object.parent
+                initial['interface'] = instance.assigned_object
+            elif type(instance.assigned_object) is VLAN:
+                initial['vlan'] = instance.assigned_object
+            elif type(instance.assigned_object) is VMInterface:
+                initial['vminterface'] = instance.assigned_object
+            kwargs['initial'] = initial
+
+        super().__init__(*args, **kwargs)
+
+    def clean(self):
+        super().clean()
+
+        interface = self.cleaned_data.get('interface')
+        vminterface = self.cleaned_data.get('vminterface')
+        vlan = self.cleaned_data.get('vlan')
+
+        if not (interface or vminterface or vlan):
+            raise ValidationError('A termination must specify an interface or VLAN.')
+        if len([x for x in (interface, vminterface, vlan) if x]) > 1:
+            raise ValidationError('A termination can only have one terminating object (an interface or VLAN).')
+
+        self.instance.assigned_object = interface or vminterface or vlan

+ 6 - 0
netbox/ipam/graphql/schema.py

@@ -17,6 +17,12 @@ class IPAMQuery(graphene.ObjectType):
     ip_range = ObjectField(IPRangeType)
     ip_range = ObjectField(IPRangeType)
     ip_range_list = ObjectListField(IPRangeType)
     ip_range_list = ObjectListField(IPRangeType)
 
 
+    l2vpn = ObjectField(L2VPNType)
+    l2vpn_list = ObjectListField(L2VPNType)
+
+    l2vpn_termination = ObjectField(L2VPNTerminationType)
+    l2vpn_termination_list = ObjectListField(L2VPNTerminationType)
+
     prefix = ObjectField(PrefixType)
     prefix = ObjectField(PrefixType)
     prefix_list = ObjectListField(PrefixType)
     prefix_list = ObjectListField(PrefixType)
 
 

+ 16 - 0
netbox/ipam/graphql/types.py

@@ -11,6 +11,8 @@ __all__ = (
     'FHRPGroupAssignmentType',
     'FHRPGroupAssignmentType',
     'IPAddressType',
     'IPAddressType',
     'IPRangeType',
     'IPRangeType',
+    'L2VPNType',
+    'L2VPNTerminationType',
     'PrefixType',
     'PrefixType',
     'RIRType',
     'RIRType',
     'RoleType',
     'RoleType',
@@ -151,3 +153,17 @@ class VRFType(NetBoxObjectType):
         model = models.VRF
         model = models.VRF
         fields = '__all__'
         fields = '__all__'
         filterset_class = filtersets.VRFFilterSet
         filterset_class = filtersets.VRFFilterSet
+
+
+class L2VPNType(NetBoxObjectType):
+    class Meta:
+        model = models.L2VPN
+        fields = '__all__'
+        filtersets_class = filtersets.L2VPNFilterSet
+
+
+class L2VPNTerminationType(NetBoxObjectType):
+    class Meta:
+        model = models.L2VPNTermination
+        fields = '__all__'
+        filtersets_class = filtersets.L2VPNTerminationFilterSet

+ 62 - 0
netbox/ipam/migrations/0059_l2vpn.py

@@ -0,0 +1,62 @@
+# Generated by Django 4.0.5 on 2022-07-06 16:51
+
+import django.core.serializers.json
+from django.db import migrations, models
+import django.db.models.deletion
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0076_configcontext_locations'),
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('tenancy', '0007_contact_link'),
+        ('ipam', '0058_ipaddress_nat_inside_nonunique'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='L2VPN',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('created', models.DateTimeField(auto_now_add=True, null=True)),
+                ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('name', models.CharField(max_length=100, unique=True)),
+                ('slug', models.SlugField()),
+                ('type', models.CharField(max_length=50)),
+                ('identifier', models.BigIntegerField(blank=True, null=True, unique=True)),
+                ('description', models.TextField(blank=True, null=True)),
+                ('export_targets', models.ManyToManyField(blank=True, related_name='exporting_l2vpns', to='ipam.routetarget')),
+                ('import_targets', models.ManyToManyField(blank=True, related_name='importing_l2vpns', to='ipam.routetarget')),
+                ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+                ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='l2vpns', to='tenancy.tenant')),
+            ],
+            options={
+                'verbose_name': 'L2VPN',
+                'ordering': ('identifier', 'name'),
+            },
+        ),
+        migrations.CreateModel(
+            name='L2VPNTermination',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('created', models.DateTimeField(auto_now_add=True, null=True)),
+                ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('assigned_object_id', models.PositiveBigIntegerField()),
+                ('assigned_object_type', models.ForeignKey(limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'dcim'), ('model', 'interface')), models.Q(('app_label', 'ipam'), ('model', 'vlan')), models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), _connector='OR')), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')),
+                ('l2vpn', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='ipam.l2vpn')),
+                ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+            ],
+            options={
+                'verbose_name': 'L2VPN Termination',
+                'ordering': ('l2vpn',),
+            },
+        ),
+        migrations.AddConstraint(
+            model_name='l2vpntermination',
+            constraint=models.UniqueConstraint(fields=('assigned_object_type', 'assigned_object_id'), name='ipam_l2vpntermination_assigned_object'),
+        ),
+    ]

+ 3 - 0
netbox/ipam/models/__init__.py

@@ -2,6 +2,7 @@
 from .fhrp import *
 from .fhrp import *
 from .vrfs import *
 from .vrfs import *
 from .ip import *
 from .ip import *
+from .l2vpn import *
 from .services import *
 from .services import *
 from .vlans import *
 from .vlans import *
 
 
@@ -12,6 +13,8 @@ __all__ = (
     'IPRange',
     'IPRange',
     'FHRPGroup',
     'FHRPGroup',
     'FHRPGroupAssignment',
     'FHRPGroupAssignment',
+    'L2VPN',
+    'L2VPNTermination',
     'Prefix',
     'Prefix',
     'RIR',
     'RIR',
     'Role',
     'Role',

+ 28 - 0
netbox/ipam/models/ip.py

@@ -857,6 +857,25 @@ class IPAddress(NetBoxModel):
             address__net_host=str(self.address.ip)
             address__net_host=str(self.address.ip)
         ).exclude(pk=self.pk)
         ).exclude(pk=self.pk)
 
 
+    def get_next_available_ip(self):
+        """
+        Return the next available IP address within this IP's network (if any)
+        """
+        if self.address and self.address.broadcast:
+            start_ip = self.address.ip + 1
+            end_ip = self.address.broadcast - 1
+            if start_ip <= end_ip:
+                available_ips = netaddr.IPSet(netaddr.IPRange(start_ip, end_ip))
+                available_ips -= netaddr.IPSet([
+                    address.ip for address in IPAddress.objects.filter(
+                        vrf=self.vrf,
+                        address__gt=self.address,
+                        address__net_contained_or_equal=self.address.cidr
+                    ).values_list('address', flat=True)
+                ])
+                if available_ips:
+                    return next(iter(available_ips))
+
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
 
 
@@ -907,6 +926,15 @@ class IPAddress(NetBoxModel):
 
 
         super().save(*args, **kwargs)
         super().save(*args, **kwargs)
 
 
+    def clone(self):
+        attrs = super().clone()
+
+        # Populate the address field with the next available IP (if any)
+        if next_available_ip := self.get_next_available_ip():
+            attrs['address'] = next_available_ip
+
+        return attrs
+
     def to_objectchange(self, action):
     def to_objectchange(self, action):
         objectchange = super().to_objectchange(action)
         objectchange = super().to_objectchange(action)
         objectchange.related_object = self.assigned_object
         objectchange.related_object = self.assigned_object

+ 112 - 0
netbox/ipam/models/l2vpn.py

@@ -0,0 +1,112 @@
+from django.contrib.contenttypes.fields import GenericRelation, GenericForeignKey
+from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ValidationError
+from django.db import models
+from django.urls import reverse
+
+from ipam.choices import L2VPNTypeChoices
+from ipam.constants import L2VPN_ASSIGNMENT_MODELS
+from netbox.models import NetBoxModel
+
+
+class L2VPN(NetBoxModel):
+    name = models.CharField(
+        max_length=100,
+        unique=True
+    )
+    slug = models.SlugField()
+    type = models.CharField(max_length=50, choices=L2VPNTypeChoices)
+    identifier = models.BigIntegerField(
+        null=True,
+        blank=True,
+        unique=True
+    )
+    import_targets = models.ManyToManyField(
+        to='ipam.RouteTarget',
+        related_name='importing_l2vpns',
+        blank=True,
+    )
+    export_targets = models.ManyToManyField(
+        to='ipam.RouteTarget',
+        related_name='exporting_l2vpns',
+        blank=True
+    )
+    description = models.TextField(null=True, blank=True)
+    tenant = models.ForeignKey(
+        to='tenancy.Tenant',
+        on_delete=models.PROTECT,
+        related_name='l2vpns',
+        blank=True,
+        null=True
+    )
+    contacts = GenericRelation(
+        to='tenancy.ContactAssignment'
+    )
+
+    class Meta:
+        ordering = ('identifier', 'name')
+        verbose_name = 'L2VPN'
+
+    def __str__(self):
+        if self.identifier:
+            return f'{self.name} ({self.identifier})'
+        return f'{self.name}'
+
+    def get_absolute_url(self):
+        return reverse('ipam:l2vpn', args=[self.pk])
+
+
+class L2VPNTermination(NetBoxModel):
+    l2vpn = models.ForeignKey(
+        to='ipam.L2VPN',
+        on_delete=models.CASCADE,
+        related_name='terminations'
+    )
+    assigned_object_type = models.ForeignKey(
+        to=ContentType,
+        limit_choices_to=L2VPN_ASSIGNMENT_MODELS,
+        on_delete=models.PROTECT,
+        related_name='+'
+    )
+    assigned_object_id = models.PositiveBigIntegerField()
+    assigned_object = GenericForeignKey(
+        ct_field='assigned_object_type',
+        fk_field='assigned_object_id'
+    )
+
+    class Meta:
+        ordering = ('l2vpn',)
+        verbose_name = 'L2VPN Termination'
+        constraints = (
+            models.UniqueConstraint(
+                fields=('assigned_object_type', 'assigned_object_id'),
+                name='ipam_l2vpntermination_assigned_object'
+            ),
+        )
+
+    def __str__(self):
+        if self.pk is not None:
+            return f'{self.assigned_object} <> {self.l2vpn}'
+        return super().__str__()
+
+    def get_absolute_url(self):
+        return reverse('ipam:l2vpntermination', args=[self.pk])
+
+    def clean(self):
+        # Only check is assigned_object is set.  Required otherwise we have an Integrity Error thrown.
+        if self.assigned_object:
+            obj_id = self.assigned_object.pk
+            obj_type = ContentType.objects.get_for_model(self.assigned_object)
+            if L2VPNTermination.objects.filter(assigned_object_id=obj_id, assigned_object_type=obj_type).\
+                    exclude(pk=self.pk).count() > 0:
+                raise ValidationError(f'L2VPN Termination already assigned ({self.assigned_object})')
+
+        # Only check if L2VPN is set and is of type P2P
+        if self.l2vpn and self.l2vpn.type in L2VPNTypeChoices.P2P:
+            terminations_count = L2VPNTermination.objects.filter(l2vpn=self.l2vpn).exclude(pk=self.pk).count()
+            if terminations_count >= 2:
+                l2vpn_type = self.l2vpn.get_type_display()
+                raise ValidationError(
+                    f'{l2vpn_type} L2VPNs cannot have more than two terminations; found {terminations_count} already '
+                    f'defined.'
+                )

+ 13 - 1
netbox/ipam/models/vlans.py

@@ -1,4 +1,4 @@
-from django.contrib.contenttypes.fields import GenericForeignKey
+from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
@@ -8,6 +8,7 @@ from django.urls import reverse
 from dcim.models import Interface
 from dcim.models import Interface
 from ipam.choices import *
 from ipam.choices import *
 from ipam.constants import *
 from ipam.constants import *
+from ipam.models import L2VPNTermination
 from ipam.querysets import VLANQuerySet
 from ipam.querysets import VLANQuerySet
 from netbox.models import OrganizationalModel, NetBoxModel
 from netbox.models import OrganizationalModel, NetBoxModel
 from virtualization.models import VMInterface
 from virtualization.models import VMInterface
@@ -173,6 +174,13 @@ class VLAN(NetBoxModel):
         blank=True
         blank=True
     )
     )
 
 
+    l2vpn_terminations = GenericRelation(
+        to='ipam.L2VPNTermination',
+        content_type_field='assigned_object_type',
+        object_id_field='assigned_object_id',
+        related_query_name='vlan'
+    )
+
     objects = VLANQuerySet.as_manager()
     objects = VLANQuerySet.as_manager()
 
 
     clone_fields = [
     clone_fields = [
@@ -227,3 +235,7 @@ class VLAN(NetBoxModel):
             Q(untagged_vlan_id=self.pk) |
             Q(untagged_vlan_id=self.pk) |
             Q(tagged_vlans=self.pk)
             Q(tagged_vlans=self.pk)
         ).distinct()
         ).distinct()
+
+    @property
+    def l2vpn_termination(self):
+        return self.l2vpn_terminations.first()

+ 1 - 0
netbox/ipam/tables/__init__.py

@@ -1,5 +1,6 @@
 from .fhrp import *
 from .fhrp import *
 from .ip import *
 from .ip import *
+from .l2vpn import *
 from .services import *
 from .services import *
 from .vlans import *
 from .vlans import *
 from .vrfs import *
 from .vrfs import *

+ 57 - 0
netbox/ipam/tables/l2vpn.py

@@ -0,0 +1,57 @@
+import django_tables2 as tables
+
+from ipam.models import *
+from ipam.models.l2vpn import L2VPN, L2VPNTermination
+from netbox.tables import NetBoxTable, columns
+
+__all__ = (
+    'L2VPNTable',
+    'L2VPNTerminationTable',
+)
+
+L2VPN_TARGETS = """
+{% for rt in value.all %}
+  <a href="{{ rt.get_absolute_url }}">{{ rt }}</a>{% if not forloop.last %}<br />{% endif %}
+{% endfor %}
+"""
+
+
+class L2VPNTable(NetBoxTable):
+    pk = columns.ToggleColumn()
+    name = tables.Column(
+        linkify=True
+    )
+    import_targets = columns.TemplateColumn(
+        template_code=L2VPN_TARGETS,
+        orderable=False
+    )
+    export_targets = columns.TemplateColumn(
+        template_code=L2VPN_TARGETS,
+        orderable=False
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = L2VPN
+        fields = ('pk', 'name', 'slug', 'type', 'description', 'import_targets', 'export_targets', 'tenant', 'actions')
+        default_columns = ('pk', 'name', 'type', 'description', 'actions')
+
+
+class L2VPNTerminationTable(NetBoxTable):
+    pk = columns.ToggleColumn()
+    l2vpn = tables.Column(
+        verbose_name='L2VPN',
+        linkify=True
+    )
+    assigned_object_type = columns.ContentTypeColumn(
+        verbose_name='Object Type'
+    )
+    assigned_object = tables.Column(
+        verbose_name='Assigned Object',
+        linkify=True,
+        orderable=False
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = L2VPNTermination
+        fields = ('pk', 'l2vpn', 'assigned_object_type', 'assigned_object', 'actions')
+        default_columns = ('pk', 'l2vpn', 'assigned_object_type', 'assigned_object', 'actions')

+ 95 - 0
netbox/ipam/tests/test_api.py

@@ -914,3 +914,98 @@ class ServiceTest(APIViewTestCases.APIViewTestCase):
                 'ports': [6],
                 'ports': [6],
             },
             },
         ]
         ]
+
+
+class L2VPNTest(APIViewTestCases.APIViewTestCase):
+    model = L2VPN
+    brief_fields = ['display', 'id', 'identifier', 'name', 'slug', 'type', 'url']
+    create_data = [
+        {
+            'name': 'L2VPN 4',
+            'slug': 'l2vpn-4',
+            'type': 'vxlan',
+            'identifier': 33343344
+        },
+        {
+            'name': 'L2VPN 5',
+            'slug': 'l2vpn-5',
+            'type': 'vxlan',
+            'identifier': 33343345
+        },
+        {
+            'name': 'L2VPN 6',
+            'slug': 'l2vpn-6',
+            'type': 'vpws',
+            'identifier': 33343346
+        },
+    ]
+    bulk_update_data = {
+        'description': 'New description',
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+
+        l2vpns = (
+            L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=650001),
+            L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002),
+            L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'),  # No RD
+        )
+        L2VPN.objects.bulk_create(l2vpns)
+
+
+class L2VPNTerminationTest(APIViewTestCases.APIViewTestCase):
+    model = L2VPNTermination
+    brief_fields = ['display', 'id', 'l2vpn', 'url']
+
+    @classmethod
+    def setUpTestData(cls):
+
+        vlans = (
+            VLAN(name='VLAN 1', vid=651),
+            VLAN(name='VLAN 2', vid=652),
+            VLAN(name='VLAN 3', vid=653),
+            VLAN(name='VLAN 4', vid=654),
+            VLAN(name='VLAN 5', vid=655),
+            VLAN(name='VLAN 6', vid=656),
+            VLAN(name='VLAN 7', vid=657)
+        )
+
+        VLAN.objects.bulk_create(vlans)
+
+        l2vpns = (
+            L2VPN(name='L2VPN 1', type='vxlan', identifier=650001),
+            L2VPN(name='L2VPN 2', type='vpws', identifier=650002),
+            L2VPN(name='L2VPN 3', type='vpls'),  # No RD
+        )
+        L2VPN.objects.bulk_create(l2vpns)
+
+        l2vpnterminations = (
+            L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]),
+            L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]),
+            L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2])
+        )
+
+        L2VPNTermination.objects.bulk_create(l2vpnterminations)
+
+        cls.create_data = [
+            {
+                'l2vpn': l2vpns[0].pk,
+                'assigned_object_type': 'ipam.vlan',
+                'assigned_object_id': vlans[3].pk,
+            },
+            {
+                'l2vpn': l2vpns[0].pk,
+                'assigned_object_type': 'ipam.vlan',
+                'assigned_object_id': vlans[4].pk,
+            },
+            {
+                'l2vpn': l2vpns[0].pk,
+                'assigned_object_type': 'ipam.vlan',
+                'assigned_object_id': vlans[5].pk,
+            },
+        ]
+
+        cls.bulk_update_data = {
+            'l2vpn': l2vpns[2].pk
+        }

+ 97 - 0
netbox/ipam/tests/test_filtersets.py

@@ -1463,3 +1463,100 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         params = {'virtual_machine': [vms[0].name, vms[1].name]}
         params = {'virtual_machine': [vms[0].name, vms[1].name]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = L2VPN.objects.all()
+    filterset = L2VPNFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+
+        l2vpns = (
+            L2VPN(name='L2VPN 1', type='vxlan', identifier=650001),
+            L2VPN(name='L2VPN 2', type='vpws', identifier=650002),
+            L2VPN(name='L2VPN 3', type='vpls'),  # No RD
+        )
+        L2VPN.objects.bulk_create(l2vpns)
+
+
+class L2VPNTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = L2VPNTermination.objects.all()
+    filterset = L2VPNTerminationFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+
+        site = Site.objects.create(name='Site 1')
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1')
+        device_type = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer)
+        device_role = DeviceRole.objects.create(name='Switch')
+        device = Device.objects.create(
+            name='Device 1',
+            site=site,
+            device_type=device_type,
+            device_role=device_role,
+            status='active'
+        )
+
+        interfaces = (
+            Interface(name='Interface 1', device=device, type='1000baset'),
+            Interface(name='Interface 2', device=device, type='1000baset'),
+            Interface(name='Interface 3', device=device, type='1000baset'),
+            Interface(name='Interface 4', device=device, type='1000baset'),
+            Interface(name='Interface 5', device=device, type='1000baset'),
+            Interface(name='Interface 6', device=device, type='1000baset')
+        )
+
+        Interface.objects.bulk_create(interfaces)
+
+        vlans = (
+            VLAN(name='VLAN 1', vid=651),
+            VLAN(name='VLAN 2', vid=652),
+            VLAN(name='VLAN 3', vid=653),
+            VLAN(name='VLAN 4', vid=654),
+            VLAN(name='VLAN 5', vid=655)
+        )
+
+        VLAN.objects.bulk_create(vlans)
+
+        l2vpns = (
+            L2VPN(name='L2VPN 1', type='vxlan', identifier=650001),
+            L2VPN(name='L2VPN 2', type='vpws', identifier=650002),
+            L2VPN(name='L2VPN 3', type='vpls'),  # No RD,
+        )
+        L2VPN.objects.bulk_create(l2vpns)
+
+        l2vpnterminations = (
+            L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]),
+            L2VPNTermination(l2vpn=l2vpns[1], assigned_object=vlans[1]),
+            L2VPNTermination(l2vpn=l2vpns[2], assigned_object=vlans[2]),
+            L2VPNTermination(l2vpn=l2vpns[0], assigned_object=interfaces[0]),
+            L2VPNTermination(l2vpn=l2vpns[1], assigned_object=interfaces[1]),
+            L2VPNTermination(l2vpn=l2vpns[2], assigned_object=interfaces[2]),
+        )
+
+        L2VPNTermination.objects.bulk_create(l2vpnterminations)
+
+    def test_l2vpns(self):
+        l2vpns = L2VPN.objects.all()[:2]
+        params = {'l2vpn_id': [l2vpns[0].pk, l2vpns[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        params = {'l2vpn': ['L2VPN 1', 'L2VPN 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+    def test_interfaces(self):
+        interfaces = Interface.objects.all()[:2]
+        params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]}
+        qs = self.filterset(params, self.queryset).qs
+        results = qs.all()
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'interface': ['Interface 1', 'Interface 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_vlans(self):
+        vlans = VLAN.objects.all()[:2]
+        params = {'vlan_id': [vlans[0].pk, vlans[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'vlan': ['VLAN 1', 'VLAN 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

+ 75 - 1
netbox/ipam/tests/test_models.py

@@ -2,8 +2,9 @@ from netaddr import IPNetwork, IPSet
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.test import TestCase, override_settings
 from django.test import TestCase, override_settings
 
 
+from dcim.models import Interface, Device, DeviceRole, DeviceType, Manufacturer, Site
 from ipam.choices import IPAddressRoleChoices, PrefixStatusChoices
 from ipam.choices import IPAddressRoleChoices, PrefixStatusChoices
-from ipam.models import Aggregate, IPAddress, IPRange, Prefix, RIR, VLAN, VLANGroup, VRF
+from ipam.models import Aggregate, IPAddress, IPRange, Prefix, RIR, VLAN, VLANGroup, VRF, L2VPN, L2VPNTermination
 
 
 
 
 class TestAggregate(TestCase):
 class TestAggregate(TestCase):
@@ -538,3 +539,76 @@ class TestVLANGroup(TestCase):
 
 
         VLAN.objects.create(name='VLAN 104', vid=104, group=vlangroup)
         VLAN.objects.create(name='VLAN 104', vid=104, group=vlangroup)
         self.assertEqual(vlangroup.get_next_available_vid(), 105)
         self.assertEqual(vlangroup.get_next_available_vid(), 105)
+
+
+class TestL2VPNTermination(TestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+
+        site = Site.objects.create(name='Site 1')
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1')
+        device_type = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer)
+        device_role = DeviceRole.objects.create(name='Switch')
+        device = Device.objects.create(
+            name='Device 1',
+            site=site,
+            device_type=device_type,
+            device_role=device_role,
+            status='active'
+        )
+
+        interfaces = (
+            Interface(name='Interface 1', device=device, type='1000baset'),
+            Interface(name='Interface 2', device=device, type='1000baset'),
+            Interface(name='Interface 3', device=device, type='1000baset'),
+            Interface(name='Interface 4', device=device, type='1000baset'),
+            Interface(name='Interface 5', device=device, type='1000baset'),
+        )
+
+        Interface.objects.bulk_create(interfaces)
+
+        vlans = (
+            VLAN(name='VLAN 1', vid=651),
+            VLAN(name='VLAN 2', vid=652),
+            VLAN(name='VLAN 3', vid=653),
+            VLAN(name='VLAN 4', vid=654),
+            VLAN(name='VLAN 5', vid=655),
+            VLAN(name='VLAN 6', vid=656),
+            VLAN(name='VLAN 7', vid=657)
+        )
+
+        VLAN.objects.bulk_create(vlans)
+
+        l2vpns = (
+            L2VPN(name='L2VPN 1', type='vxlan', identifier=650001),
+            L2VPN(name='L2VPN 2', type='vpws', identifier=650002),
+            L2VPN(name='L2VPN 3', type='vpls'),  # No RD
+        )
+        L2VPN.objects.bulk_create(l2vpns)
+
+        l2vpnterminations = (
+            L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]),
+            L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]),
+            L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2])
+        )
+
+        L2VPNTermination.objects.bulk_create(l2vpnterminations)
+
+    def test_duplicate_interface_terminations(self):
+        device = Device.objects.first()
+        interface = Interface.objects.filter(device=device).first()
+        l2vpn = L2VPN.objects.first()
+
+        L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=interface)
+        duplicate = L2VPNTermination(l2vpn=l2vpn, assigned_object=interface)
+
+        self.assertRaises(ValidationError, duplicate.clean)
+
+    def test_duplicate_vlan_terminations(self):
+        vlan = Interface.objects.first()
+        l2vpn = L2VPN.objects.first()
+
+        L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=vlan)
+        duplicate = L2VPNTermination(l2vpn=l2vpn, assigned_object=vlan)
+        self.assertRaises(ValidationError, duplicate.clean)

+ 136 - 2
netbox/ipam/tests/test_views.py

@@ -1,14 +1,18 @@
 import datetime
 import datetime
 
 
+from django.contrib.contenttypes.models import ContentType
 from django.test import override_settings
 from django.test import override_settings
 from django.urls import reverse
 from django.urls import reverse
 from netaddr import IPNetwork
 from netaddr import IPNetwork
 
 
-from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
+from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site, Interface
+from extras.choices import ObjectChangeActionChoices
+from extras.models import ObjectChange
 from ipam.choices import *
 from ipam.choices import *
 from ipam.models import *
 from ipam.models import *
 from tenancy.models import Tenant
 from tenancy.models import Tenant
-from utilities.testing import ViewTestCases, create_tags
+from users.models import ObjectPermission
+from utilities.testing import ViewTestCases, create_tags, post_data
 
 
 
 
 class ASNTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 class ASNTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@@ -746,3 +750,133 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         self.assertEqual(instance.protocol, service_template.protocol)
         self.assertEqual(instance.protocol, service_template.protocol)
         self.assertEqual(instance.ports, service_template.ports)
         self.assertEqual(instance.ports, service_template.ports)
         self.assertEqual(instance.description, service_template.description)
         self.assertEqual(instance.description, service_template.description)
+
+
+class L2VPNTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+    model = L2VPN
+    csv_data = (
+        'name,slug,type,identifier',
+        'L2VPN 5,l2vpn-5,vxlan,456',
+        'L2VPN 6,l2vpn-6,vxlan,444',
+    )
+    bulk_edit_data = {
+        'description': 'New Description',
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+        rts = (
+            RouteTarget(name='64534:123'),
+            RouteTarget(name='64534:321')
+        )
+        RouteTarget.objects.bulk_create(rts)
+
+        l2vpns = (
+            L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier='650001'),
+            L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vxlan', identifier='650002'),
+            L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vxlan', identifier='650003')
+        )
+
+        L2VPN.objects.bulk_create(l2vpns)
+
+        cls.form_data = {
+            'name': 'L2VPN 8',
+            'slug': 'l2vpn-8',
+            'type': 'vxlan',
+            'identifier': 123,
+            'description': 'Description',
+            'import_targets': [rts[0].pk],
+            'export_targets': [rts[1].pk]
+        }
+
+        print(cls.form_data)
+
+
+class L2VPNTerminationTestCase(
+        ViewTestCases.GetObjectViewTestCase,
+        ViewTestCases.GetObjectChangelogViewTestCase,
+        ViewTestCases.CreateObjectViewTestCase,
+        ViewTestCases.EditObjectViewTestCase,
+        ViewTestCases.DeleteObjectViewTestCase,
+        ViewTestCases.ListObjectsViewTestCase,
+        ViewTestCases.BulkImportObjectsViewTestCase,
+        ViewTestCases.BulkDeleteObjectsViewTestCase,
+):
+
+    model = L2VPNTermination
+
+    @classmethod
+    def setUpTestData(cls):
+        site = Site.objects.create(name='Site 1')
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1')
+        device_type = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer)
+        device_role = DeviceRole.objects.create(name='Switch')
+        device = Device.objects.create(
+            name='Device 1',
+            site=site,
+            device_type=device_type,
+            device_role=device_role,
+            status='active'
+        )
+
+        interface = Interface.objects.create(name='Interface 1', device=device, type='1000baset')
+        l2vpn = L2VPN.objects.create(name='L2VPN 1', type='vxlan', identifier=650001)
+        l2vpn_vlans = L2VPN.objects.create(name='L2VPN 2', type='vxlan', identifier=650002)
+
+        vlans = (
+            VLAN(name='Vlan 1', vid=1001),
+            VLAN(name='Vlan 2', vid=1002),
+            VLAN(name='Vlan 3', vid=1003),
+            VLAN(name='Vlan 4', vid=1004),
+            VLAN(name='Vlan 5', vid=1005),
+            VLAN(name='Vlan 6', vid=1006)
+        )
+        VLAN.objects.bulk_create(vlans)
+
+        terminations = (
+            L2VPNTermination(l2vpn=l2vpn, assigned_object=vlans[0]),
+            L2VPNTermination(l2vpn=l2vpn, assigned_object=vlans[1]),
+            L2VPNTermination(l2vpn=l2vpn, assigned_object=vlans[2])
+        )
+        L2VPNTermination.objects.bulk_create(terminations)
+
+        cls.form_data = {
+            'l2vpn': l2vpn.pk,
+            'device': device.pk,
+            'interface': interface.pk,
+        }
+
+        cls.csv_data = (
+            "l2vpn,vlan",
+            "L2VPN 2,Vlan 4",
+            "L2VPN 2,Vlan 5",
+            "L2VPN 2,Vlan 6",
+        )
+
+        cls.bulk_edit_data = {}
+
+    #
+    # Custom assertions
+    #
+
+    def assertInstanceEqual(self, instance, data, exclude=None, api=False):
+        """
+        Override parent
+        """
+        if exclude is None:
+            exclude = []
+
+        fields = [k for k in data.keys() if k not in exclude]
+        model_dict = self.model_to_dict(instance, fields=fields, api=api)
+
+        # Omit any dictionary keys which are not instance attributes or have been excluded
+        relevant_data = {
+            k: v for k, v in data.items() if hasattr(instance, k) and k not in exclude
+        }
+
+        # Handle relations on the model
+        for k, v in model_dict.items():
+            if isinstance(v, object) and hasattr(v, 'first'):
+                model_dict[k] = v.first().pk
+
+        self.assertDictEqual(model_dict, relevant_data)

+ 22 - 0
netbox/ipam/urls.py

@@ -186,4 +186,26 @@ urlpatterns = [
     path('services/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}),
     path('services/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}),
     path('services/<int:pk>/journal/', ObjectJournalView.as_view(), name='service_journal', kwargs={'model': Service}),
     path('services/<int:pk>/journal/', ObjectJournalView.as_view(), name='service_journal', kwargs={'model': Service}),
 
 
+    # L2VPN
+    path('l2vpns/', views.L2VPNListView.as_view(), name='l2vpn_list'),
+    path('l2vpns/add/', views.L2VPNEditView.as_view(), name='l2vpn_add'),
+    path('l2vpns/import/', views.L2VPNBulkImportView.as_view(), name='l2vpn_import'),
+    path('l2vpns/edit/', views.L2VPNBulkEditView.as_view(), name='l2vpn_bulk_edit'),
+    path('l2vpns/delete/', views.L2VPNBulkDeleteView.as_view(), name='l2vpn_bulk_delete'),
+    path('l2vpns/<int:pk>/', views.L2VPNView.as_view(), name='l2vpn'),
+    path('l2vpns/<int:pk>/edit/', views.L2VPNEditView.as_view(), name='l2vpn_edit'),
+    path('l2vpns/<int:pk>/delete/', views.L2VPNDeleteView.as_view(), name='l2vpn_delete'),
+    path('l2vpns/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='l2vpn_changelog', kwargs={'model': L2VPN}),
+    path('l2vpns/<int:pk>/journal/', ObjectJournalView.as_view(), name='l2vpn_journal', kwargs={'model': L2VPN}),
+
+    path('l2vpn-terminations/', views.L2VPNTerminationListView.as_view(), name='l2vpntermination_list'),
+    path('l2vpn-terminations/add/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_add'),
+    path('l2vpn-terminations/import/', views.L2VPNTerminationBulkImportView.as_view(), name='l2vpntermination_import'),
+    path('l2vpn-terminations/edit/', views.L2VPNTerminationBulkEditView.as_view(), name='l2vpntermination_bulk_edit'),
+    path('l2vpn-terminations/delete/', views.L2VPNTerminationBulkDeleteView.as_view(), name='l2vpntermination_bulk_delete'),
+    path('l2vpn-terminations/<int:pk>/', views.L2VPNTerminationView.as_view(), name='l2vpntermination'),
+    path('l2vpn-terminations/<int:pk>/edit/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_edit'),
+    path('l2vpn-terminations/<int:pk>/delete/', views.L2VPNTerminationDeleteView.as_view(), name='l2vpntermination_delete'),
+    path('l2vpn-terminations/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='l2vpntermination_changelog', kwargs={'model': L2VPNTermination}),
+    path('l2vpn-terminations/<int:pk>/journal/', ObjectJournalView.as_view(), name='l2vpntermination_journal', kwargs={'model': L2VPNTermination}),
 ]
 ]

+ 112 - 6
netbox/ipam/views.py

@@ -17,6 +17,7 @@ from . import filtersets, forms, tables
 from .constants import *
 from .constants import *
 from .models import *
 from .models import *
 from .models import ASN
 from .models import ASN
+from .tables.l2vpn import L2VPNTable, L2VPNTerminationTable
 from .utils import add_requested_prefixes, add_available_ipaddresses, add_available_vlans
 from .utils import add_requested_prefixes, add_available_ipaddresses, add_available_vlans
 
 
 
 
@@ -680,13 +681,16 @@ class IPAddressView(generic.ObjectView):
         service_filter = Q(ipaddresses=instance)
         service_filter = Q(ipaddresses=instance)
 
 
         # Find services listening on all IPs on the assigned device/vm
         # 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
+        try:
+            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))
+                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))
+        except AttributeError:
+            pass
 
 
         services = Service.objects.restrict(request.user, 'view').filter(service_filter)
         services = Service.objects.restrict(request.user, 'view').filter(service_filter)
 
 
@@ -1147,3 +1151,105 @@ class ServiceBulkDeleteView(generic.BulkDeleteView):
     queryset = Service.objects.prefetch_related('device', 'virtual_machine')
     queryset = Service.objects.prefetch_related('device', 'virtual_machine')
     filterset = filtersets.ServiceFilterSet
     filterset = filtersets.ServiceFilterSet
     table = tables.ServiceTable
     table = tables.ServiceTable
+
+
+# L2VPN
+
+
+class L2VPNListView(generic.ObjectListView):
+    queryset = L2VPN.objects.all()
+    table = L2VPNTable
+    filterset = filtersets.L2VPNFilterSet
+    filterset_form = forms.L2VPNFilterForm
+
+
+class L2VPNView(generic.ObjectView):
+    queryset = L2VPN.objects.all()
+
+    def get_extra_context(self, request, instance):
+        terminations = L2VPNTermination.objects.restrict(request.user, 'view').filter(l2vpn=instance)
+        terminations_table = tables.L2VPNTerminationTable(terminations, user=request.user, exclude=('l2vpn', ))
+        terminations_table.configure(request)
+
+        import_targets_table = tables.RouteTargetTable(
+            instance.import_targets.prefetch_related('tenant'),
+            orderable=False
+        )
+        export_targets_table = tables.RouteTargetTable(
+            instance.export_targets.prefetch_related('tenant'),
+            orderable=False
+        )
+
+        return {
+            'terminations_table': terminations_table,
+            'import_targets_table': import_targets_table,
+            'export_targets_table': export_targets_table,
+        }
+
+
+class L2VPNEditView(generic.ObjectEditView):
+    queryset = L2VPN.objects.all()
+    form = forms.L2VPNForm
+
+
+class L2VPNDeleteView(generic.ObjectDeleteView):
+    queryset = L2VPN.objects.all()
+
+
+class L2VPNBulkImportView(generic.BulkImportView):
+    queryset = L2VPN.objects.all()
+    model_form = forms.L2VPNCSVForm
+    table = tables.L2VPNTable
+
+
+class L2VPNBulkEditView(generic.BulkEditView):
+    queryset = L2VPN.objects.all()
+    filterset = filtersets.L2VPNFilterSet
+    table = tables.L2VPNTable
+    form = forms.L2VPNBulkEditForm
+
+
+class L2VPNBulkDeleteView(generic.BulkDeleteView):
+    queryset = L2VPN.objects.all()
+    filterset = filtersets.L2VPNFilterSet
+    table = tables.L2VPNTable
+
+
+class L2VPNTerminationListView(generic.ObjectListView):
+    queryset = L2VPNTermination.objects.all()
+    table = L2VPNTerminationTable
+    filterset = filtersets.L2VPNTerminationFilterSet
+    filterset_form = forms.L2VPNTerminationFilterForm
+
+
+class L2VPNTerminationView(generic.ObjectView):
+    queryset = L2VPNTermination.objects.all()
+
+
+class L2VPNTerminationEditView(generic.ObjectEditView):
+    queryset = L2VPNTermination.objects.all()
+    form = forms.L2VPNTerminationForm
+    template_name = 'ipam/l2vpntermination_edit.html'
+
+
+class L2VPNTerminationDeleteView(generic.ObjectDeleteView):
+    queryset = L2VPNTermination.objects.all()
+
+
+class L2VPNTerminationBulkImportView(generic.BulkImportView):
+    queryset = L2VPNTermination.objects.all()
+    model_form = forms.L2VPNTerminationCSVForm
+    table = tables.L2VPNTerminationTable
+
+
+class L2VPNTerminationBulkEditView(generic.BulkEditView):
+    queryset = L2VPNTermination.objects.all()
+    filterset = filtersets.L2VPNTerminationFilterSet
+    table = tables.L2VPNTerminationTable
+    form = forms.L2VPNTerminationBulkEditForm
+
+
+class L2VPNTerminationBulkDeleteView(generic.BulkDeleteView):
+    queryset = L2VPNTermination.objects.all()
+    filterset = filtersets.L2VPNTerminationFilterSet
+    table = tables.L2VPNTerminationTable

+ 9 - 0
netbox/netbox/api/viewsets/__init__.py

@@ -11,6 +11,7 @@ from rest_framework.viewsets import ModelViewSet
 from extras.models import ExportTemplate
 from extras.models import ExportTemplate
 from netbox.api.exceptions import SerializerNotFound
 from netbox.api.exceptions import SerializerNotFound
 from utilities.api import get_serializer_for_model
 from utilities.api import get_serializer_for_model
+from utilities.exceptions import AbortRequest
 from .mixins import *
 from .mixins import *
 
 
 __all__ = (
 __all__ = (
@@ -125,6 +126,14 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali
                 *args,
                 *args,
                 **kwargs
                 **kwargs
             )
             )
+        except AbortRequest as e:
+            logger.debug(e.message)
+            return self.finalize_response(
+                request,
+                Response({'detail': e.message}, status=400),
+                *args,
+                **kwargs
+            )
 
 
     def list(self, request, *args, **kwargs):
     def list(self, request, *args, **kwargs):
         """
         """

+ 36 - 13
netbox/netbox/authentication.py

@@ -8,8 +8,11 @@ from django.contrib.auth.models import Group, AnonymousUser
 from django.core.exceptions import ImproperlyConfigured
 from django.core.exceptions import ImproperlyConfigured
 from django.db.models import Q
 from django.db.models import Q
 
 
+from users.constants import CONSTRAINT_TOKEN_USER
 from users.models import ObjectPermission
 from users.models import ObjectPermission
-from utilities.permissions import permission_is_exempt, resolve_permission, resolve_permission_ct
+from utilities.permissions import (
+    permission_is_exempt, qs_filter_from_constraints, resolve_permission, resolve_permission_ct,
+)
 
 
 UserModel = get_user_model()
 UserModel = get_user_model()
 
 
@@ -99,8 +102,10 @@ class ObjectPermissionMixin:
         if not user_obj.is_active or user_obj.is_anonymous:
         if not user_obj.is_active or user_obj.is_anonymous:
             return False
             return False
 
 
+        object_permissions = self.get_all_permissions(user_obj)
+
         # If no applicable ObjectPermissions have been created for this user/permission, deny permission
         # If no applicable ObjectPermissions have been created for this user/permission, deny permission
-        if perm not in self.get_all_permissions(user_obj):
+        if perm not in object_permissions:
             return False
             return False
 
 
         # If no object has been specified, grant permission. (The presence of a permission in this set tells
         # If no object has been specified, grant permission. (The presence of a permission in this set tells
@@ -113,21 +118,16 @@ class ObjectPermissionMixin:
         if model._meta.label_lower != '.'.join((app_label, model_name)):
         if model._meta.label_lower != '.'.join((app_label, model_name)):
             raise ValueError(f"Invalid permission {perm} for model {model}")
             raise ValueError(f"Invalid permission {perm} for model {model}")
 
 
-        # Compile a query filter that matches all instances of the specified model
-        obj_perm_constraints = self.get_all_permissions(user_obj)[perm]
-        constraints = Q()
-        for perm_constraints in obj_perm_constraints:
-            if perm_constraints:
-                constraints |= Q(**perm_constraints)
-            else:
-                # Found ObjectPermission with null constraints; allow model-level access
-                constraints = Q()
-                break
+        # Compile a QuerySet filter that matches all instances of the specified model
+        tokens = {
+            CONSTRAINT_TOKEN_USER: user_obj,
+        }
+        qs_filter = qs_filter_from_constraints(object_permissions[perm], tokens)
 
 
         # Permission to perform the requested action on the object depends on whether the specified object matches
         # Permission to perform the requested action on the object depends on whether the specified object matches
         # the specified constraints. Note that this check is made against the *database* record representing the object,
         # the specified constraints. Note that this check is made against the *database* record representing the object,
         # not the instance itself.
         # not the instance itself.
-        return model.objects.filter(constraints, pk=obj.pk).exists()
+        return model.objects.filter(qs_filter, pk=obj.pk).exists()
 
 
 
 
 class ObjectPermissionBackend(ObjectPermissionMixin, ModelBackend):
 class ObjectPermissionBackend(ObjectPermissionMixin, ModelBackend):
@@ -348,3 +348,26 @@ class LDAPBackend:
             ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
             ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
 
 
         return obj
         return obj
+
+
+# Custom Social Auth Pipeline Handlers
+def user_default_groups_handler(backend, user, response, *args, **kwargs):
+    """
+    Custom pipeline handler which adds remote auth users to the default group specified in the
+    configuration file.
+    """
+    logger = logging.getLogger('netbox.auth.user_default_groups_handler')
+    if settings.REMOTE_AUTH_DEFAULT_GROUPS:
+        # Assign default groups to the user
+        group_list = []
+        for name in settings.REMOTE_AUTH_DEFAULT_GROUPS:
+            try:
+                group_list.append(Group.objects.get(name=name))
+            except Group.DoesNotExist:
+                logging.error(
+                    f"Could not assign group {name} to remotely-authenticated user {user}: Group not found")
+        if group_list:
+            user.groups.add(*group_list)
+        else:
+            user.groups.clear()
+            logger.debug(f"Stripping user {user} from Groups")

+ 25 - 0
netbox/netbox/config/parameters.py

@@ -82,6 +82,31 @@ PARAMS = (
         field=forms.IntegerField
         field=forms.IntegerField
     ),
     ),
 
 
+    # Power
+    ConfigParam(
+        name='POWERFEED_DEFAULT_VOLTAGE',
+        label='Powerfeed voltage',
+        default=120,
+        description="Default voltage for powerfeeds",
+        field=forms.IntegerField
+    ),
+
+    ConfigParam(
+        name='POWERFEED_DEFAULT_AMPERAGE',
+        label='Powerfeed amperage',
+        default=15,
+        description="Default amperage for powerfeeds",
+        field=forms.IntegerField
+    ),
+
+    ConfigParam(
+        name='POWERFEED_DEFAULT_MAX_UTILIZATION',
+        label='Powerfeed max utilization',
+        default=80,
+        description="Default max utilization for powerfeeds",
+        field=forms.IntegerField
+    ),
+
     # Security
     # Security
     ConfigParam(
     ConfigParam(
         name='ALLOWED_URL_SCHEMES',
         name='ALLOWED_URL_SCHEMES',

+ 11 - 3
netbox/netbox/models/features.py

@@ -49,11 +49,19 @@ class ChangeLoggingMixin(models.Model):
     class Meta:
     class Meta:
         abstract = True
         abstract = True
 
 
+    def serialize_object(self):
+        """
+        Return a JSON representation of the instance. Models can override this method to replace or extend the default
+        serialization logic provided by the `serialize_object()` utility function.
+        """
+        return serialize_object(self)
+
     def snapshot(self):
     def snapshot(self):
         """
         """
-        Save a snapshot of the object's current state in preparation for modification.
+        Save a snapshot of the object's current state in preparation for modification. The snapshot is saved as
+        `_prechange_snapshot` on the instance.
         """
         """
-        self._prechange_snapshot = serialize_object(self)
+        self._prechange_snapshot = self.serialize_object()
 
 
     def to_objectchange(self, action):
     def to_objectchange(self, action):
         """
         """
@@ -69,7 +77,7 @@ class ChangeLoggingMixin(models.Model):
         if hasattr(self, '_prechange_snapshot'):
         if hasattr(self, '_prechange_snapshot'):
             objectchange.prechange_data = self._prechange_snapshot
             objectchange.prechange_data = self._prechange_snapshot
         if action in (ObjectChangeActionChoices.ACTION_CREATE, ObjectChangeActionChoices.ACTION_UPDATE):
         if action in (ObjectChangeActionChoices.ACTION_CREATE, ObjectChangeActionChoices.ACTION_UPDATE):
-            objectchange.postchange_data = serialize_object(self)
+            objectchange.postchange_data = self.serialize_object()
 
 
         return objectchange
         return objectchange
 
 

+ 7 - 0
netbox/netbox/navigation_menu.py

@@ -260,6 +260,13 @@ IPAM_MENU = Menu(
                 get_model_item('ipam', 'vlangroup', 'VLAN Groups'),
                 get_model_item('ipam', 'vlangroup', 'VLAN Groups'),
             ),
             ),
         ),
         ),
+        MenuGroup(
+            label='L2VPNs',
+            items=(
+                get_model_item('ipam', 'l2vpn', 'L2VPNs'),
+                get_model_item('ipam', 'l2vpntermination', 'Terminations'),
+            ),
+        ),
         MenuGroup(
         MenuGroup(
             label='Other',
             label='Other',
             items=(
             items=(

+ 13 - 0
netbox/netbox/settings.py

@@ -485,6 +485,19 @@ for param in dir(configuration):
 
 
 SOCIAL_AUTH_JSONFIELD_ENABLED = True
 SOCIAL_AUTH_JSONFIELD_ENABLED = True
 
 
+SOCIAL_AUTH_PIPELINE = (
+    'social_core.pipeline.social_auth.social_details',
+    'social_core.pipeline.social_auth.social_uid',
+    'social_core.pipeline.social_auth.social_user',
+    'social_core.pipeline.user.get_username',
+    'social_core.pipeline.social_auth.associate_by_email',
+    'social_core.pipeline.user.create_user',
+    'social_core.pipeline.social_auth.associate_user',
+    'netbox.authentication.user_default_groups_handler',
+    'social_core.pipeline.social_auth.load_extra_data',
+    'social_core.pipeline.user.user_details',
+)
+
 
 
 #
 #
 # Django Prometheus
 # Django Prometheus

+ 33 - 59
netbox/netbox/views/generic/bulk_views.py

@@ -1,6 +1,5 @@
 import logging
 import logging
 import re
 import re
-from collections import defaultdict
 from copy import deepcopy
 from copy import deepcopy
 
 
 from django.contrib import messages
 from django.contrib import messages
@@ -12,11 +11,12 @@ from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput
 from django.http import HttpResponse
 from django.http import HttpResponse
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
 from django_tables2.export import TableExport
 from django_tables2.export import TableExport
+from django.utils.safestring import mark_safe
 
 
 from extras.models import ExportTemplate
 from extras.models import ExportTemplate
 from extras.signals import clear_webhooks
 from extras.signals import clear_webhooks
 from utilities.error_handlers import handle_protectederror
 from utilities.error_handlers import handle_protectederror
-from utilities.exceptions import PermissionsViolation
+from utilities.exceptions import AbortRequest, PermissionsViolation
 from utilities.forms import (
 from utilities.forms import (
     BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, CSVFileField, restrict_form_fields,
     BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, CSVFileField, restrict_form_fields,
 )
 )
@@ -24,6 +24,7 @@ from utilities.htmx import is_htmx
 from utilities.permissions import get_permission_for_model
 from utilities.permissions import get_permission_for_model
 from utilities.views import GetReturnURLMixin
 from utilities.views import GetReturnURLMixin
 from .base import BaseMultiObjectView
 from .base import BaseMultiObjectView
+from .mixins import ActionsMixin, TableMixin
 
 
 __all__ = (
 __all__ = (
     'BulkComponentCreateView',
     'BulkComponentCreateView',
@@ -36,9 +37,9 @@ __all__ = (
 )
 )
 
 
 
 
-class ObjectListView(BaseMultiObjectView):
+class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
     """
     """
-    Display multiple objects, all of the same type, as a table.
+    Display multiple objects, all the same type, as a table.
 
 
     Attributes:
     Attributes:
         filterset: A django-filter FilterSet that is applied to the queryset
         filterset: A django-filter FilterSet that is applied to the queryset
@@ -50,31 +51,10 @@ class ObjectListView(BaseMultiObjectView):
     template_name = 'generic/object_list.html'
     template_name = 'generic/object_list.html'
     filterset = None
     filterset = None
     filterset_form = None
     filterset_form = None
-    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete')
-    action_perms = defaultdict(set, **{
-        'add': {'add'},
-        'import': {'add'},
-        'bulk_edit': {'change'},
-        'bulk_delete': {'delete'},
-    })
 
 
     def get_required_permission(self):
     def get_required_permission(self):
         return get_permission_for_model(self.queryset.model, 'view')
         return get_permission_for_model(self.queryset.model, 'view')
 
 
-    def get_table(self, request, bulk_actions=True):
-        """
-        Return the django-tables2 Table instance to be used for rendering the objects list.
-
-        Args:
-            request: The current request
-            bulk_actions: Show checkboxes for object selection
-        """
-        table = self.table(self.queryset, user=request.user)
-        if 'pk' in table.base_columns and bulk_actions:
-            table.columns.show('pk')
-
-        return table
-
     #
     #
     # Export methods
     # Export methods
     #
     #
@@ -147,19 +127,14 @@ class ObjectListView(BaseMultiObjectView):
             self.queryset = self.filterset(request.GET, self.queryset).qs
             self.queryset = self.filterset(request.GET, self.queryset).qs
 
 
         # Determine the available actions
         # Determine the available actions
-        actions = []
-        for action in self.actions:
-            if request.user.has_perms([
-                get_permission_for_model(model, name) for name in self.action_perms[action]
-            ]):
-                actions.append(action)
+        actions = self.get_permitted_actions(request.user)
         has_bulk_actions = any([a.startswith('bulk_') for a in actions])
         has_bulk_actions = any([a.startswith('bulk_') for a in actions])
 
 
         if 'export' in request.GET:
         if 'export' in request.GET:
 
 
             # Export the current table view
             # Export the current table view
             if request.GET['export'] == 'table':
             if request.GET['export'] == 'table':
-                table = self.get_table(request, has_bulk_actions)
+                table = self.get_table(self.queryset, request, has_bulk_actions)
                 columns = [name for name, _ in table.selected_columns]
                 columns = [name for name, _ in table.selected_columns]
                 return self.export_table(table, columns)
                 return self.export_table(table, columns)
 
 
@@ -177,12 +152,11 @@ class ObjectListView(BaseMultiObjectView):
 
 
             # Fall back to default table/YAML export
             # Fall back to default table/YAML export
             else:
             else:
-                table = self.get_table(request, has_bulk_actions)
+                table = self.get_table(self.queryset, request, has_bulk_actions)
                 return self.export_table(table)
                 return self.export_table(table)
 
 
         # Render the objects table
         # Render the objects table
-        table = self.get_table(request, has_bulk_actions)
-        table.configure(request)
+        table = self.get_table(self.queryset, request, has_bulk_actions)
 
 
         # If this is an HTMX request, return only the rendered table HTML
         # If this is an HTMX request, return only the rendered table HTML
         if is_htmx(request):
         if is_htmx(request):
@@ -190,15 +164,13 @@ class ObjectListView(BaseMultiObjectView):
                 'table': table,
                 'table': table,
             })
             })
 
 
-        context = {
+        return render(request, self.template_name, {
             'model': model,
             'model': model,
             'table': table,
             'table': table,
             'actions': actions,
             'actions': actions,
             'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None,
             'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None,
             **self.get_extra_context(request),
             **self.get_extra_context(request),
-        }
-
-        return render(request, self.template_name, context)
+        })
 
 
 
 
 class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
 class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
@@ -292,10 +264,10 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
             except IntegrityError:
             except IntegrityError:
                 pass
                 pass
 
 
-            except PermissionsViolation:
-                msg = "Object creation failed due to object-level permissions violation"
-                logger.debug(msg)
-                form.add_error(None, msg)
+            except (AbortRequest, PermissionsViolation) as e:
+                logger.debug(e.message)
+                form.add_error(None, e.message)
+                clear_webhooks.send(sender=self)
 
 
         else:
         else:
             logger.debug("Form validation failed")
             logger.debug("Form validation failed")
@@ -420,10 +392,9 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
             except ValidationError:
             except ValidationError:
                 clear_webhooks.send(sender=self)
                 clear_webhooks.send(sender=self)
 
 
-            except PermissionsViolation:
-                msg = "Object import failed due to object-level permissions violation"
-                logger.debug(msg)
-                form.add_error(None, msg)
+            except (AbortRequest, PermissionsViolation) as e:
+                logger.debug(e.message)
+                form.add_error(None, e.message)
                 clear_webhooks.send(sender=self)
                 clear_webhooks.send(sender=self)
 
 
         else:
         else:
@@ -570,10 +541,9 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
                     messages.error(self.request, ", ".join(e.messages))
                     messages.error(self.request, ", ".join(e.messages))
                     clear_webhooks.send(sender=self)
                     clear_webhooks.send(sender=self)
 
 
-                except PermissionsViolation:
-                    msg = "Object update failed due to object-level permissions violation"
-                    logger.debug(msg)
-                    form.add_error(None, msg)
+                except (AbortRequest, PermissionsViolation) as e:
+                    logger.debug(e.message)
+                    form.add_error(None, e.message)
                     clear_webhooks.send(sender=self)
                     clear_webhooks.send(sender=self)
 
 
             else:
             else:
@@ -667,10 +637,9 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
                             messages.success(request, f"Renamed {len(selected_objects)} {model_name}")
                             messages.success(request, f"Renamed {len(selected_objects)} {model_name}")
                             return redirect(self.get_return_url(request))
                             return redirect(self.get_return_url(request))
 
 
-                except PermissionsViolation:
-                    msg = "Object update failed due to object-level permissions violation"
-                    logger.debug(msg)
-                    form.add_error(None, msg)
+                except (AbortRequest, PermissionsViolation) as e:
+                    logger.debug(e.message)
+                    form.add_error(None, e.message)
                     clear_webhooks.send(sender=self)
                     clear_webhooks.send(sender=self)
 
 
         else:
         else:
@@ -745,11 +714,17 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView):
                         if hasattr(obj, 'snapshot'):
                         if hasattr(obj, 'snapshot'):
                             obj.snapshot()
                             obj.snapshot()
                         obj.delete()
                         obj.delete()
+
                 except ProtectedError as e:
                 except ProtectedError as e:
                     logger.info("Caught ProtectedError while attempting to delete objects")
                     logger.info("Caught ProtectedError while attempting to delete objects")
                     handle_protectederror(queryset, request, e)
                     handle_protectederror(queryset, request, e)
                     return redirect(self.get_return_url(request))
                     return redirect(self.get_return_url(request))
 
 
+                except AbortRequest as e:
+                    logger.debug(e.message)
+                    messages.error(request, mark_safe(e.message))
+                    return redirect(self.get_return_url(request))
+
                 msg = f"Deleted {deleted_count} {model._meta.verbose_name_plural}"
                 msg = f"Deleted {deleted_count} {model._meta.verbose_name_plural}"
                 logger.info(msg)
                 logger.info(msg)
                 messages.success(request, msg)
                 messages.success(request, msg)
@@ -857,10 +832,9 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView):
                 except IntegrityError:
                 except IntegrityError:
                     clear_webhooks.send(sender=self)
                     clear_webhooks.send(sender=self)
 
 
-                except PermissionsViolation:
-                    msg = "Component creation failed due to object-level permissions violation"
-                    logger.debug(msg)
-                    form.add_error(None, msg)
+                except (AbortRequest, PermissionsViolation) as e:
+                    logger.debug(e.message)
+                    form.add_error(None, e.message)
                     clear_webhooks.send(sender=self)
                     clear_webhooks.send(sender=self)
 
 
                 if not form.errors:
                 if not form.errors:

+ 48 - 0
netbox/netbox/views/generic/mixins.py

@@ -0,0 +1,48 @@
+from collections import defaultdict
+
+from utilities.permissions import get_permission_for_model
+
+__all__ = (
+    'ActionsMixin',
+    'TableMixin',
+)
+
+
+class ActionsMixin:
+    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete')
+    action_perms = defaultdict(set, **{
+        'add': {'add'},
+        'import': {'add'},
+        'bulk_edit': {'change'},
+        'bulk_delete': {'delete'},
+    })
+
+    def get_permitted_actions(self, user, model=None):
+        """
+        Return a tuple of actions for which the given user is permitted to do.
+        """
+        model = model or self.queryset.model
+        return [
+            action for action in self.actions if user.has_perms([
+                get_permission_for_model(model, name) for name in self.action_perms[action]
+            ])
+        ]
+
+
+class TableMixin:
+
+    def get_table(self, data, request, bulk_actions=True):
+        """
+        Return the django-tables2 Table instance to be used for rendering the objects list.
+
+        Args:
+            data: Queryset or iterable containing table data
+            request: The current request
+            bulk_actions: Render checkboxes for object selection
+        """
+        table = self.table(data, user=request.user)
+        if 'pk' in table.base_columns and bulk_actions:
+            table.columns.show('pk')
+        table.configure(request)
+
+        return table

+ 33 - 27
netbox/netbox/views/generic/object_views.py

@@ -13,13 +13,14 @@ from django.utils.safestring import mark_safe
 
 
 from extras.signals import clear_webhooks
 from extras.signals import clear_webhooks
 from utilities.error_handlers import handle_protectederror
 from utilities.error_handlers import handle_protectederror
-from utilities.exceptions import AbortTransaction, PermissionsViolation
+from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation
 from utilities.forms import ConfirmationForm, ImportForm, restrict_form_fields
 from utilities.forms import ConfirmationForm, ImportForm, restrict_form_fields
 from utilities.htmx import is_htmx
 from utilities.htmx import is_htmx
 from utilities.permissions import get_permission_for_model
 from utilities.permissions import get_permission_for_model
 from utilities.utils import get_viewname, normalize_querydict, prepare_cloned_fields
 from utilities.utils import get_viewname, normalize_querydict, prepare_cloned_fields
 from utilities.views import GetReturnURLMixin
 from utilities.views import GetReturnURLMixin
 from .base import BaseObjectView
 from .base import BaseObjectView
+from .mixins import ActionsMixin, TableMixin
 
 
 __all__ = (
 __all__ = (
     'ComponentCreateView',
     'ComponentCreateView',
@@ -69,12 +70,17 @@ class ObjectView(BaseObjectView):
         })
         })
 
 
 
 
-class ObjectChildrenView(ObjectView):
+class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
     """
     """
     Display a table of child objects associated with the parent object.
     Display a table of child objects associated with the parent object.
 
 
     Attributes:
     Attributes:
-        table: Table class used to render child objects list
+        child_model: The model class which represents the child objects
+        table: The django-tables2 Table class used to render the child objects list
+        filterset: A django-filter FilterSet that is applied to the queryset
+        actions: Supported actions for the model. When adding custom actions, bulk action names must
+            be prefixed with `bulk_`. Default actions: add, import, export, bulk_edit, bulk_delete
+        action_perms: A dictionary mapping supported actions to a set of permissions required for each
     """
     """
     child_model = None
     child_model = None
     table = None
     table = None
@@ -84,8 +90,9 @@ class ObjectChildrenView(ObjectView):
         """
         """
         Return a QuerySet of child objects.
         Return a QuerySet of child objects.
 
 
-        request: The current request
-        parent: The parent object
+        Args:
+            request: The current request
+            parent: The parent object
         """
         """
         raise NotImplementedError(f'{self.__class__.__name__} must implement get_children()')
         raise NotImplementedError(f'{self.__class__.__name__} must implement get_children()')
 
 
@@ -114,16 +121,11 @@ class ObjectChildrenView(ObjectView):
         if self.filterset:
         if self.filterset:
             child_objects = self.filterset(request.GET, child_objects).qs
             child_objects = self.filterset(request.GET, child_objects).qs
 
 
-        permissions = {}
-        for action in ('change', 'delete'):
-            perm_name = get_permission_for_model(self.child_model, action)
-            permissions[action] = request.user.has_perm(perm_name)
+        # Determine the available actions
+        actions = self.get_permitted_actions(request.user, model=self.child_model)
 
 
-        table = self.table(self.prep_table_data(request, child_objects, instance), user=request.user)
-        # Determine whether to display bulk action checkboxes
-        if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
-            table.columns.show('pk')
-        table.configure(request)
+        table_data = self.prep_table_data(request, child_objects, instance)
+        table = self.get_table(table_data, request, bool(actions))
 
 
         # If this is an HTMX request, return only the rendered table HTML
         # If this is an HTMX request, return only the rendered table HTML
         if is_htmx(request):
         if is_htmx(request):
@@ -134,8 +136,9 @@ class ObjectChildrenView(ObjectView):
 
 
         return render(request, self.get_template_name(), {
         return render(request, self.get_template_name(), {
             'object': instance,
             'object': instance,
+            'child_model': self.child_model,
             'table': table,
             'table': table,
-            'permissions': permissions,
+            'actions': actions,
             **self.get_extra_context(request, instance),
             **self.get_extra_context(request, instance),
         })
         })
 
 
@@ -243,10 +246,9 @@ class ObjectImportView(GetReturnURLMixin, BaseObjectView):
                 except AbortTransaction:
                 except AbortTransaction:
                     clear_webhooks.send(sender=self)
                     clear_webhooks.send(sender=self)
 
 
-                except PermissionsViolation:
-                    msg = "Object creation failed due to object-level permissions violation"
-                    logger.debug(msg)
-                    form.add_error(None, msg)
+                except (AbortRequest, PermissionsViolation) as e:
+                    logger.debug(e.message)
+                    form.add_error(None, e.message)
                     clear_webhooks.send(sender=self)
                     clear_webhooks.send(sender=self)
 
 
             if not model_form.errors:
             if not model_form.errors:
@@ -407,10 +409,9 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
 
 
                 return redirect(return_url)
                 return redirect(return_url)
 
 
-            except PermissionsViolation:
-                msg = "Object save failed due to object-level permissions violation"
-                logger.debug(msg)
-                form.add_error(None, msg)
+            except (AbortRequest, PermissionsViolation) as e:
+                logger.debug(e.message)
+                form.add_error(None, e.message)
                 clear_webhooks.send(sender=self)
                 clear_webhooks.send(sender=self)
 
 
         else:
         else:
@@ -486,11 +487,17 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView):
 
 
             try:
             try:
                 obj.delete()
                 obj.delete()
+
             except ProtectedError as e:
             except ProtectedError as e:
                 logger.info("Caught ProtectedError while attempting to delete object")
                 logger.info("Caught ProtectedError while attempting to delete object")
                 handle_protectederror([obj], request, e)
                 handle_protectederror([obj], request, e)
                 return redirect(obj.get_absolute_url())
                 return redirect(obj.get_absolute_url())
 
 
+            except AbortRequest as e:
+                logger.debug(e.message)
+                messages.error(request, mark_safe(e.message))
+                return redirect(obj.get_absolute_url())
+
             msg = 'Deleted {} {}'.format(self.queryset.model._meta.verbose_name, obj)
             msg = 'Deleted {} {}'.format(self.queryset.model._meta.verbose_name, obj)
             logger.info(msg)
             logger.info(msg)
             messages.success(request, msg)
             messages.success(request, msg)
@@ -600,10 +607,9 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
                         else:
                         else:
                             return redirect(self.get_return_url(request))
                             return redirect(self.get_return_url(request))
 
 
-                except PermissionsViolation:
-                    msg = "Component creation failed due to object-level permissions violation"
-                    logger.debug(msg)
-                    form.add_error(None, msg)
+                except (AbortRequest, PermissionsViolation) as e:
+                    logger.debug(e.message)
+                    form.add_error(None, e.message)
                     clear_webhooks.send(sender=self)
                     clear_webhooks.send(sender=self)
 
 
         return render(request, self.template_name, {
         return render(request, self.template_name, {

+ 70 - 66
netbox/templates/circuits/circuit.html

@@ -8,74 +8,78 @@
 {% endblock %}
 {% endblock %}
 
 
 {% block content %}
 {% block content %}
-<div class="row">
-	<div class="col col-md-6">
-        <div class="card">
-            <h5 class="card-header">
-                Circuit
-            </h5>
-            <div class="card-body">
-                <table class="table table-hover attr-table">
-                    <tr>
-                        <th scope="row">Provider</th>
-                        <td>{{ object.provider|linkify }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">Circuit ID</th>
-                        <td>{{ object.cid }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">Type</th>
-                        <td>{{ object.type|linkify }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">Status</th>
-                        <td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">Tenant</th>
-                        <td>
-                            {% if object.tenant.group %}
-                                {{ object.tenant.group|linkify }} /
-                            {% endif %}
-                            {{ object.tenant|linkify|placeholder }}
-                        </td>
-                    </tr>
-                    <tr>
-                        <th scope="row">Install Date</th>
-                        <td>{{ object.install_date|annotated_date|placeholder }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">Termination Date</th>
-                        <td>{{ object.termination_date|annotated_date|placeholder }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">Commit Rate</th>
-                        <td>{{ object.commit_rate|humanize_speed|placeholder }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">Description</th>
-                        <td>{{ object.description|placeholder }}</td>
-                    </tr>
-                </table>
-            </div>
+  <div class="row">
+    <div class="col col-md-6">
+      <div class="card">
+        <h5 class="card-header">Circuit</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">Provider</th>
+              <td>{{ object.provider|linkify }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Circuit ID</th>
+              <td>{{ object.cid }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Type</th>
+              <td>{{ object.type|linkify }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Status</th>
+              <td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
+            </tr>
+            <tr>
+              <th scope="row">Tenant</th>
+              <td>
+                {% if object.tenant.group %}
+                  {{ object.tenant.group|linkify }} /
+                {% endif %}
+                {{ object.tenant|linkify|placeholder }}
+              </td>
+            </tr>
+            <tr>
+              <th scope="row">Install Date</th>
+              <td>{{ object.install_date|annotated_date|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Termination Date</th>
+              <td>{{ object.termination_date|annotated_date|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Commit Rate</th>
+              <td>{{ object.commit_rate|humanize_speed|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Description</th>
+              <td>{{ object.description|placeholder }}</td>
+            </tr>
+          </table>
         </div>
         </div>
-        {% include 'inc/panels/custom_fields.html' %}
-        {% include 'inc/panels/tags.html' %}
-        {% include 'inc/panels/comments.html' %}
-        {% plugin_left_page object %}
-	</div>
-	<div class="col col-md-6">
-    {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %}
-    {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
-    {% include 'inc/panels/contacts.html' %}
-    {% include 'inc/panels/image_attachments.html' %}
-    {% plugin_right_page object %}
+      </div>
+      {% include 'inc/panels/custom_fields.html' %}
+      {% include 'inc/panels/tags.html' %}
+      {% plugin_left_page object %}
+    </div>
+    <div class="col col-md-6">
+      {% include 'inc/panels/comments.html' %}
+      {% include 'inc/panels/contacts.html' %}
+      {% include 'inc/panels/image_attachments.html' %}
+      {% plugin_right_page object %}
+    </div>
+  </div>
+  <div class="row">
+    <div class="col col-md-6">
+      {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %}
+    </div>
+    <div class="col col-md-6">
+      {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
+    </div>
   </div>
   </div>
-</div>
-<div class="row">
+  <div class="row">
     <div class="col col-md-12">
     <div class="col col-md-12">
-        {% plugin_full_width_page object %}
+      {% plugin_full_width_page object %}
     </div>
     </div>
-</div>
+  </div>
 {% endblock %}
 {% endblock %}

+ 8 - 0
netbox/templates/circuits/circuittermination_edit.html

@@ -10,6 +10,7 @@
     {% render_field form.provider %}
     {% render_field form.provider %}
     {% render_field form.circuit %}
     {% render_field form.circuit %}
     {% render_field form.term_side %}
     {% render_field form.term_side %}
+    {% render_field form.tags %}
     {% render_field form.mark_connected %}
     {% render_field form.mark_connected %}
     {% with providernetwork_tab_active=form.initial.provider_network %}
     {% with providernetwork_tab_active=form.initial.provider_network %}
       <div class="row mb-2">
       <div class="row mb-2">
@@ -47,6 +48,13 @@
     {% render_field form.pp_info %}
     {% render_field form.pp_info %}
     {% render_field form.description %}
     {% render_field form.description %}
   </div>
   </div>
+
+  <div class="field-group my-5">
+    <div class="row mb-2">
+      <h5 class="offset-sm-3">Custom Fields</h5>
+    </div>
+    {% render_custom_fields form %}
+  </div>
 {% endblock %}
 {% endblock %}
 
 
 {# Override buttons block, 'Create & Add Another'/'_addanother' is not needed on a circuit. #}
 {# Override buttons block, 'Create & Add Another'/'_addanother' is not needed on a circuit. #}

+ 30 - 3
netbox/templates/circuits/inc/circuit_termination.html

@@ -2,7 +2,6 @@
 
 
 <div class="card">
 <div class="card">
     <div class="card-header">
     <div class="card-header">
-        <strong class="d-block d-md-inline mb-3 mb-md-0">Termination - {{ side }} Side</strong>
         <div class="float-md-end">
         <div class="float-md-end">
             {% if not termination and perms.circuits.add_circuittermination %}
             {% if not termination and perms.circuits.add_circuittermination %}
                 <a href="{% url 'circuits:circuittermination_add' %}?circuit={{ object.pk }}&term_side={{ side }}&return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-success lh-1">
                 <a href="{% url 'circuits:circuittermination_add' %}?circuit={{ object.pk }}&term_side={{ side }}&return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-success lh-1">
@@ -10,10 +9,10 @@
                 </a>
                 </a>
             {% endif %}
             {% endif %}
             {% if termination and perms.circuits.change_circuittermination %}
             {% if termination and perms.circuits.change_circuittermination %}
-                <a href="{% url 'circuits:circuittermination_edit' pk=termination.pk %}" class="btn btn-sm btn-warning lh-1">
+                <a href="{% url 'circuits:circuittermination_edit' pk=termination.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-warning lh-1">
                     <span class="mdi mdi-pencil" aria-hidden="true"></span> Edit
                     <span class="mdi mdi-pencil" aria-hidden="true"></span> Edit
                 </a>
                 </a>
-                <a href="{% url 'circuits:circuit_terminations_swap' pk=object.pk %}" class="btn btn-sm btn-primary lh-1">
+                <a href="{% url 'circuits:circuit_terminations_swap' pk=object.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-primary lh-1">
                     <span class="mdi mdi-swap-vertical" aria-hidden="true"></span> Swap
                     <span class="mdi mdi-swap-vertical" aria-hidden="true"></span> Swap
                 </a>
                 </a>
             {% endif %}
             {% endif %}
@@ -23,6 +22,7 @@
                 </a>
                 </a>
             {% endif %}
             {% endif %}
         </div>
         </div>
+        <h5>Termination {{ side }}</h5>
     </div>
     </div>
     <div class="card-body">
     <div class="card-body">
       {% if termination %}
       {% if termination %}
@@ -109,6 +109,33 @@
                 <td>Description</td>
                 <td>Description</td>
                 <td>{{ termination.description|placeholder }}</td>
                 <td>{{ termination.description|placeholder }}</td>
             </tr>
             </tr>
+            <tr>
+              <td>Tags</td>
+              <td>
+                {% for tag in termination.tags.all %}
+                  {% tag tag %}
+                {% empty %}
+                  {{ ''|placeholder }}
+                {% endfor %}
+              </td>
+            </tr>
+          {% for group_name, fields in termination.get_custom_fields_by_group.items %}
+            <tr>
+              <td colspan="2">
+                <strong>{{ group_name|default:"Custom Fields" }}</strong>
+              </td>
+            </tr>
+            {% for field, value in fields.items %}
+              <tr>
+                <td>
+                  <span title="{{ field.description|escape }}">{{ field }}</span>
+                </td>
+                <td>
+                  {% customfield_value field value %}
+                </td>
+              </tr>
+            {% endfor %}
+          {% endfor %}
         </table>
         </table>
     {% else %}
     {% else %}
         <span class="text-muted">None</span>
         <span class="text-muted">None</span>

+ 2 - 2
netbox/templates/dcim/device/consoleports.html

@@ -17,7 +17,7 @@
 
 
     <div class="noprint bulk-buttons">
     <div class="noprint bulk-buttons">
         <div class="bulk-button-group">
         <div class="bulk-button-group">
-            {% if perms.dcim.change_consoleport %}
+            {% if 'bulk_edit' in actions %}
                 <button type="submit" name="_rename" formaction="{% url 'dcim:consoleport_bulk_rename' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
                 <button type="submit" name="_rename" formaction="{% url 'dcim:consoleport_bulk_rename' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
                     <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
                     <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
                 </button>
                 </button>
@@ -28,7 +28,7 @@
                     <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
                     <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
                 </button>
                 </button>
             {% endif %}
             {% endif %}
-            {% if perms.dcim.delete_consoleport %}
+            {% if 'bulk_delete' in actions %}
                 <button type="submit" name="_delete" formaction="{% url 'dcim:consoleport_bulk_delete' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}" class="btn btn-danger btn-sm">
                 <button type="submit" name="_delete" formaction="{% url 'dcim:consoleport_bulk_delete' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}" class="btn btn-danger btn-sm">
                     <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
                     <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
                 </button>
                 </button>

+ 2 - 2
netbox/templates/dcim/device/consoleserverports.html

@@ -17,7 +17,7 @@
 
 
     <div class="noprint bulk-buttons">
     <div class="noprint bulk-buttons">
         <div class="bulk-button-group">
         <div class="bulk-button-group">
-            {% if perms.dcim.change_consoleserverport %}
+            {% if 'bulk_edit' in actions %}
                 <button type="submit" name="_rename" formaction="{% url 'dcim:consoleserverport_bulk_rename' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
                 <button type="submit" name="_rename" formaction="{% url 'dcim:consoleserverport_bulk_rename' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
                     <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
                     <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
                 </button>
                 </button>
@@ -28,7 +28,7 @@
                     <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
                     <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
                 </button>
                 </button>
             {% endif %}
             {% endif %}
-            {% if perms.dcim.delete_consoleserverport %}
+            {% if 'bulk_delete' in actions %}
                 <button type="submit" formaction="{% url 'dcim:consoleserverport_bulk_delete' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}" class="btn btn-danger btn-sm">
                 <button type="submit" formaction="{% url 'dcim:consoleserverport_bulk_delete' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}" class="btn btn-danger btn-sm">
                     <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
                     <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
                 </button>
                 </button>

+ 2 - 2
netbox/templates/dcim/device/devicebays.html

@@ -17,7 +17,7 @@
 
 
     <div class="noprint bulk-buttons">
     <div class="noprint bulk-buttons">
         <div class="bulk-button-group">
         <div class="bulk-button-group">
-            {% if perms.dcim.change_devicebay %}
+            {% if 'bulk_edit' in actions %}
                 <button type="submit" name="_rename" formaction="{% url 'dcim:devicebay_bulk_rename' %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
                 <button type="submit" name="_rename" formaction="{% url 'dcim:devicebay_bulk_rename' %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
                     <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
                     <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
                 </button>
                 </button>
@@ -25,7 +25,7 @@
                     <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
                     <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
                 </button>
                 </button>
             {% endif %}
             {% endif %}
-            {% if perms.dcim.delete_devicebay %}
+            {% if 'bulk_delete' in actions %}
                 <button type="submit" formaction="{% url 'dcim:devicebay_bulk_delete' %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-outline-danger btn-sm">
                 <button type="submit" formaction="{% url 'dcim:devicebay_bulk_delete' %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-outline-danger btn-sm">
                     <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete selected
                     <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete selected
                 </button>
                 </button>

+ 2 - 2
netbox/templates/dcim/device/frontports.html

@@ -17,7 +17,7 @@
 
 
     <div class="noprint bulk-buttons">
     <div class="noprint bulk-buttons">
         <div class="bulk-button-group">
         <div class="bulk-button-group">
-            {% if perms.dcim.change_frontport %}
+            {% if 'bulk_edit' in actions %}
                 <button type="submit" name="_rename" formaction="{% url 'dcim:frontport_bulk_rename' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
                 <button type="submit" name="_rename" formaction="{% url 'dcim:frontport_bulk_rename' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
                     <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
                     <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
                 </button>
                 </button>
@@ -28,7 +28,7 @@
                     <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
                     <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
                 </button>
                 </button>
             {% endif %}
             {% endif %}
-            {% if perms.dcim.delete_frontport %}
+            {% if 'bulk_delete' in actions %}
                 <button type="submit" formaction="{% url 'dcim:frontport_bulk_delete' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}" class="btn btn-danger btn-sm">
                 <button type="submit" formaction="{% url 'dcim:frontport_bulk_delete' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}" class="btn btn-danger btn-sm">
                     <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
                     <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
                 </button>
                 </button>

+ 2 - 2
netbox/templates/dcim/device/interfaces.html

@@ -53,7 +53,7 @@
 
 
     <div class="noprint bulk-buttons">
     <div class="noprint bulk-buttons">
         <div class="bulk-button-group">
         <div class="bulk-button-group">
-        {% if perms.dcim.change_interface %}
+          {% if 'bulk_edit' in actions %}
             <button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
             <button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
                 <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
                 <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
             </button>
             </button>
@@ -64,7 +64,7 @@
                 <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
                 <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
             </button>
             </button>
         {% endif %}
         {% endif %}
-        {% if perms.dcim.delete_interface %}
+          {% if 'bulk_delete' in actions %}
             <button type="submit" name="_delete" formaction="{% url 'dcim:interface_bulk_delete' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}" class="btn btn-danger btn-sm">
             <button type="submit" name="_delete" formaction="{% url 'dcim:interface_bulk_delete' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}" class="btn btn-danger btn-sm">
                 <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
                 <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
             </button>
             </button>

+ 2 - 2
netbox/templates/dcim/device/inventory.html

@@ -17,7 +17,7 @@
 
 
     <div class="noprint bulk-buttons">
     <div class="noprint bulk-buttons">
         <div class="bulk-button-group">
         <div class="bulk-button-group">
-            {% if perms.dcim.change_inventoryitem %}
+            {% if 'bulk_edit' in actions %}
                 <button type="submit" name="_rename" formaction="{% url 'dcim:inventoryitem_bulk_rename' %}?return_url={% url 'dcim:device_inventory' pk=object.pk %}" class="btn btn-warning btn-sm">
                 <button type="submit" name="_rename" formaction="{% url 'dcim:inventoryitem_bulk_rename' %}?return_url={% url 'dcim:device_inventory' pk=object.pk %}" class="btn btn-warning btn-sm">
                     <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
                     <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
                 </button>
                 </button>
@@ -25,7 +25,7 @@
                     <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
                     <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
                 </button>
                 </button>
             {% endif %}
             {% endif %}
-            {% if perms.dcim.delete_inventoryitem %}
+            {% if 'bulk_delete' in actions %}
                 <button type="submit" name="_delete" formaction="{% url 'dcim:inventoryitem_bulk_delete' %}?return_url={% url 'dcim:device_inventory' pk=object.pk %}" class="btn btn-danger btn-sm">
                 <button type="submit" name="_delete" formaction="{% url 'dcim:inventoryitem_bulk_delete' %}?return_url={% url 'dcim:device_inventory' pk=object.pk %}" class="btn btn-danger btn-sm">
                     <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
                     <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
                 </button>
                 </button>

+ 2 - 2
netbox/templates/dcim/device/modulebays.html

@@ -17,7 +17,7 @@
 
 
     <div class="noprint bulk-buttons">
     <div class="noprint bulk-buttons">
         <div class="bulk-button-group">
         <div class="bulk-button-group">
-            {% if perms.dcim.change_modulebay %}
+            {% if 'bulk_edit' in actions %}
                 <button type="submit" name="_rename" formaction="{% url 'dcim:modulebay_bulk_rename' %}?return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
                 <button type="submit" name="_rename" formaction="{% url 'dcim:modulebay_bulk_rename' %}?return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
                     <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
                     <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
                 </button>
                 </button>
@@ -25,7 +25,7 @@
                     <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
                     <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
                 </button>
                 </button>
             {% endif %}
             {% endif %}
-            {% if perms.dcim.delete_modulebay %}
+            {% if 'bulk_delete' in actions %}
                 <button type="submit" formaction="{% url 'dcim:modulebay_bulk_delete' %}?return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-outline-danger btn-sm">
                 <button type="submit" formaction="{% url 'dcim:modulebay_bulk_delete' %}?return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-outline-danger btn-sm">
                     <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete selected
                     <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete selected
                 </button>
                 </button>

+ 2 - 2
netbox/templates/dcim/device/poweroutlets.html

@@ -17,7 +17,7 @@
 
 
     <div class="noprint bulk-buttons">
     <div class="noprint bulk-buttons">
         <div class="bulk-button-group">
         <div class="bulk-button-group">
-            {% if perms.dcim.change_powerport %}
+            {% if 'bulk_edit' in actions %}
                 <button type="submit" name="_rename" formaction="{% url 'dcim:poweroutlet_bulk_rename' %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
                 <button type="submit" name="_rename" formaction="{% url 'dcim:poweroutlet_bulk_rename' %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
                     <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
                     <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
                 </button>
                 </button>
@@ -28,7 +28,7 @@
                     <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
                     <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
                 </button>
                 </button>
             {% endif %}
             {% endif %}
-            {% if perms.dcim.delete_poweroutlet %}
+            {% if 'bulk_delete' in actions %}
                 <button type="submit" formaction="{% url 'dcim:poweroutlet_bulk_delete' %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" class="btn btn-danger btn-sm">
                 <button type="submit" formaction="{% url 'dcim:poweroutlet_bulk_delete' %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" class="btn btn-danger btn-sm">
                     <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
                     <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
                 </button>
                 </button>

+ 2 - 2
netbox/templates/dcim/device/powerports.html

@@ -17,7 +17,7 @@
 
 
     <div class="noprint bulk-buttons">
     <div class="noprint bulk-buttons">
         <div class="bulk-button-group">
         <div class="bulk-button-group">
-            {% if perms.dcim.change_powerport %}
+            {% if 'bulk_edit' in actions %}
                 <button type="submit" name="_rename" formaction="{% url 'dcim:powerport_bulk_rename' %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
                 <button type="submit" name="_rename" formaction="{% url 'dcim:powerport_bulk_rename' %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
                     <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
                     <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
                 </button>
                 </button>
@@ -28,7 +28,7 @@
                     <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
                     <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
                 </button>
                 </button>
             {% endif %}
             {% endif %}
-            {% if perms.dcim.delete_powerport %}
+            {% if 'bulk_delete' in actions %}
                 <button type="submit" name="_delete" formaction="{% url 'dcim:powerport_bulk_delete' %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}" class="btn btn-danger btn-sm">
                 <button type="submit" name="_delete" formaction="{% url 'dcim:powerport_bulk_delete' %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}" class="btn btn-danger btn-sm">
                     <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
                     <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
                 </button>
                 </button>

+ 2 - 2
netbox/templates/dcim/device/rearports.html

@@ -17,7 +17,7 @@
 
 
     <div class="noprint bulk-buttons">
     <div class="noprint bulk-buttons">
         <div class="bulk-button-group">
         <div class="bulk-button-group">
-            {% if perms.dcim.change_rearport %}
+            {% if 'bulk_edit' in actions %}
                 <button type="submit" name="_rename" formaction="{% url 'dcim:rearport_bulk_rename' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
                 <button type="submit" name="_rename" formaction="{% url 'dcim:rearport_bulk_rename' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
                     <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
                     <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
                 </button>
                 </button>
@@ -28,7 +28,7 @@
                     <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
                     <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
                 </button>
                 </button>
             {% endif %}
             {% endif %}
-            {% if perms.dcim.delete_rearport %}
+            {% if 'bulk_delete' in actions %}
                 <button type="submit" formaction="{% url 'dcim:rearport_bulk_delete' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}" class="btn btn-danger btn-sm">
                 <button type="submit" formaction="{% url 'dcim:rearport_bulk_delete' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}" class="btn btn-danger btn-sm">
                     <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
                     <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
                 </button>
                 </button>

+ 9 - 0
netbox/templates/dcim/device_edit.html

@@ -86,6 +86,15 @@
       {% render_field form.tenant %}
       {% render_field form.tenant %}
     </div>
     </div>
 
 
+    <div class="field-group my-5">
+      <div class="row mb-2">
+        <h5 class="offset-sm-3">Virtual Chassis</h5>
+      </div>
+      {% render_field form.virtual_chassis %}
+      {% render_field form.vc_position %}
+      {% render_field form.vc_priority %}
+    </div>
+
     {% if form.custom_fields %}
     {% if form.custom_fields %}
       <div class="field-group my-5">
       <div class="field-group my-5">
         <div class="row mb-2">
         <div class="row mb-2">

+ 4 - 0
netbox/templates/dcim/interface.html

@@ -104,6 +104,10 @@
               <th scope="row">LAG</th>
               <th scope="row">LAG</th>
               <td>{{ object.lag|linkify|placeholder }}</td>
               <td>{{ object.lag|linkify|placeholder }}</td>
             </tr>
             </tr>
+            <tr>
+              <th scope="row">L2VPN</th>
+              <td>{{ object.l2vpn_termination.l2vpn|linkify|placeholder }}</td>
+            </tr>
           </table>
           </table>
         </div>
         </div>
       </div>
       </div>

+ 8 - 0
netbox/templates/dcim/interface_edit.html

@@ -72,6 +72,14 @@
         </div>
         </div>
     {% endif %}
     {% endif %}
 
 
+    <div class="field-group my-5">
+        <div class="row mb-2">
+          <h5 class="offset-sm-3">Power over Ethernet (PoE)</h5>
+        </div>
+        {% render_field form.poe_mode %}
+        {% render_field form.poe_type %}
+    </div>
+
     <div class="field-group my-5">
     <div class="field-group my-5">
         <div class="row mb-2">
         <div class="row mb-2">
           <h5 class="offset-sm-3">802.1Q Switching</h5>
           <h5 class="offset-sm-3">802.1Q Switching</h5>

+ 1 - 27
netbox/templates/inc/panels/custom_fields.html

@@ -16,33 +16,7 @@
                   <span title="{{ field.description|escape }}">{{ field }}</span>
                   <span title="{{ field.description|escape }}">{{ field }}</span>
                 </td>
                 </td>
                 <td>
                 <td>
-                  {% if field.type == 'integer' and value is not None %}
-                    {{ value }}
-                  {% elif field.type == 'longtext' and value %}
-                    {{ value|markdown }}
-                  {% elif field.type == 'boolean' and value == True %}
-                    {% checkmark value true="True" %}
-                  {% elif field.type == 'boolean' and value == False %}
-                    {% checkmark value false="False" %}
-                  {% elif field.type == 'url' and value %}
-                    <a href="{{ value }}">{{ value|truncatechars:70 }}</a>
-                  {% elif field.type == 'json' and value %}
-                    <pre>{{ value|json }}</pre>
-                  {% elif field.type == 'multiselect' and value %}
-                    {{ value|join:", " }}
-                  {% elif field.type == 'object' and value %}
-                    {{ value|linkify }}
-                  {% elif field.type == 'multiobject' and value %}
-                    {% for obj in value %}
-                      {{ obj|linkify }}{% if not forloop.last %}<br />{% endif %}
-                    {% endfor %}
-                  {% elif value %}
-                    {{ value }}
-                  {% elif field.required %}
-                    <span class="text-warning"><i class="mdi mdi-alert"></i> Not defined</span>
-                  {% else %}
-                    {{ ''|placeholder }}
-                  {% endif %}
+                  {% customfield_value field value %}
                 </td>
                 </td>
               </tr>
               </tr>
             {% endfor %}
             {% endfor %}

+ 2 - 2
netbox/templates/ipam/aggregate/prefixes.html

@@ -25,12 +25,12 @@
 
 
     <div class="noprint bulk-buttons">
     <div class="noprint bulk-buttons">
       <div class="bulk-button-group">
       <div class="bulk-button-group">
-        {% if perms.ipam.change_prefix %}
+        {% if 'bulk_edit' in actions %}
           <button type="submit" name="_edit" formaction="{% url 'ipam:prefix_bulk_edit' %}?return_url={% url 'ipam:aggregate_prefixes' pk=object.pk %}" class="btn btn-warning btn-sm">
           <button type="submit" name="_edit" formaction="{% url 'ipam:prefix_bulk_edit' %}?return_url={% url 'ipam:aggregate_prefixes' pk=object.pk %}" class="btn btn-warning btn-sm">
             <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
             <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
           </button>
           </button>
         {% endif %}
         {% endif %}
-        {% if perms.ipam.delete_prefix %}
+        {% if 'bulk_delete' in actions %}
           <button type="submit" name="_delete" formaction="{% url 'ipam:prefix_bulk_delete' %}?return_url={% url 'ipam:aggregate_prefixes' pk=object.pk %}" class="btn btn-danger btn-sm">
           <button type="submit" name="_delete" formaction="{% url 'ipam:prefix_bulk_delete' %}?return_url={% url 'ipam:aggregate_prefixes' pk=object.pk %}" class="btn btn-danger btn-sm">
             <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
             <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
           </button>
           </button>

+ 2 - 2
netbox/templates/ipam/iprange/ip_addresses.html

@@ -23,12 +23,12 @@
 
 
     <div class="noprint bulk-buttons">
     <div class="noprint bulk-buttons">
       <div class="bulk-button-group">
       <div class="bulk-button-group">
-        {% if perms.ipam.change_ipaddress %}
+        {% if 'bulk_edit' in actions %}
           <button type="submit" name="_edit" formaction="{% url 'ipam:ipaddress_bulk_edit' %}?return_url={% url 'ipam:iprange_ipaddresses' pk=object.pk %}" class="btn btn-warning btn-sm">
           <button type="submit" name="_edit" formaction="{% url 'ipam:ipaddress_bulk_edit' %}?return_url={% url 'ipam:iprange_ipaddresses' pk=object.pk %}" class="btn btn-warning btn-sm">
             <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
             <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
           </button>
           </button>
         {% endif %}
         {% endif %}
-        {% if perms.ipam.delete_ipaddress %}
+        {% if 'bulk_delete' in actions %}
           <button type="submit" name="_delete" formaction="{% url 'ipam:ipaddress_bulk_delete' %}?return_url={% url 'ipam:iprange_ipaddresses' pk=object.pk %}" class="btn btn-danger btn-sm">
           <button type="submit" name="_delete" formaction="{% url 'ipam:ipaddress_bulk_delete' %}?return_url={% url 'ipam:iprange_ipaddresses' pk=object.pk %}" class="btn btn-danger btn-sm">
             <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
             <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
           </button>
           </button>

+ 81 - 0
netbox/templates/ipam/l2vpn.html

@@ -0,0 +1,81 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+{% load render_table from django_tables2 %}
+
+{% block content %}
+<div class="row mb-3">
+	<div class="col col-md-6">
+        <div class="card">
+            <h5 class="card-header">
+                L2VPN Attributes
+            </h5>
+            <div class="card-body">
+                <table class="table table-hover attr-table
+                    <tr>
+                        <th scope="row">Name</th>
+                        <td>{{ object.name|placeholder }}</td>
+                    </tr>
+                    <tr>
+                        <th scope="row">Slug</th>
+                        <td>{{ object.slug|placeholder }}</td>
+                    </tr>
+                    <tr>
+                        <th scope="row">Identifier</th>
+                        <td>{{ object.identifier|placeholder }}</td>
+                    </tr>
+                    <tr>
+                        <th scope="row">Type</th>
+                        <td>{{ object.get_type_display }}</td>
+                    </tr>
+                    <tr>
+                        <th scope="row">Description</th>
+                        <td>{{ object.description|placeholder }}</td>
+                    </tr>
+                    <tr>
+                        <th scope="row">Tenant</th>
+                        <td>{{ object.tenant|placeholder }}</td>
+                    </tr>
+                </table>
+            </div>
+        </div>
+        {% include 'inc/panels/contacts.html' %}
+        {% plugin_left_page object %}
+	</div>
+	<div class="col col-md-6">
+        {% include 'inc/panels/tags.html' with tags=object.tags.all url='circuits:circuit_list' %}
+        {% include 'inc/panels/custom_fields.html' %}
+        {% plugin_right_page object %}
+    </div>
+</div>
+<div class="row mb-3">
+	<div class="col col-md-6">
+    {% include 'inc/panel_table.html' with table=import_targets_table heading="Import Route Targets" %}
+  </div>
+	<div class="col col-md-6">
+    {% include 'inc/panel_table.html' with table=export_targets_table heading="Export Route Targets" %}
+  </div>
+</div>
+<div class="row mb-3">
+	<div class="col col-md-12">
+        <div class="card">
+          <h5 class="card-header">Terminations</h5>
+          <div class="card-body">
+            {% render_table terminations_table 'inc/table.html' %}
+          </div>
+          {% if perms.ipam.add_l2vpntermination %}
+            <div class="card-footer text-end noprint">
+              <a href="{% url 'ipam:l2vpntermination_add' %}?l2vpn={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
+                <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add a Termination
+              </a>
+            </div>
+          {% endif %}
+        </div>
+    </div>
+</div>
+<div class="row mb-3">
+	<div class="col col-md-12">
+        {% plugin_full_width_page object %}
+  </div>
+</div>
+{% endblock %}

+ 31 - 0
netbox/templates/ipam/l2vpntermination.html

@@ -0,0 +1,31 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+
+{% block content %}
+<div class="row">
+	<div class="col col-md-6">
+        <div class="card">
+            <h5 class="card-header">
+                L2VPN Attributes
+            </h5>
+            <div class="card-body">
+                <table class="table table-hover">
+                    <tr>
+                        <th scope="row">L2VPN</th>
+                        <td>{{ object.l2vpn|linkify }}</td>
+                    </tr>
+                    <tr>
+                        <th scope="row">Assigned Object</th>
+                        <td>{{ object.assigned_object|linkify }}</td>
+                    </tr>
+                </table>
+            </div>
+        </div>
+	</div>
+	<div class="col col-md-6">
+        {% include 'inc/panels/custom_fields.html' %}
+        {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:l2vpntermination_list' %}
+    </div>
+</div>
+
+{% endblock %}

+ 49 - 0
netbox/templates/ipam/l2vpntermination_edit.html

@@ -0,0 +1,49 @@
+{% extends 'generic/object_edit.html' %}
+{% load helpers %}
+{% load form_helpers %}
+
+{% block form %}
+  <div class="field-group my-5">
+    <div class="row mb-2">
+      <h5 class="offset-sm-3">L2VPN Termination</h5>
+    </div>
+    {% render_field form.l2vpn %}
+    <div class="row mb-3">
+      <div class="offset-sm-3">
+        <ul class="nav nav-pills" role="tablist">
+          <li role="presentation" class="nav-item">
+            <button role="tab" type="button" id="vlan_tab" data-bs-toggle="tab" aria-controls="vlan" data-bs-target="#vlan" class="nav-link {% if not form.initial.interface or form.initial.vminterface %}active{% endif %}">
+              VLAN
+            </button>
+          </li>
+          <li role="presentation" class="nav-item">
+            <button role="tab" type="button" id="interface_tab" data-bs-toggle="tab" aria-controls="interface" data-bs-target="#interface" class="nav-link {% if form.initial.interface %}active{% endif %}">
+              Interface
+            </button>
+          </li>
+          <li role="presentation" class="nav-item">
+            <button role="tab" type="button" id="vminterface_tab" data-bs-toggle="tab" aria-controls="vminterface" data-bs-target="#vminterface" class="nav-link {% if form.initial.vminterface %}active{% endif %}">
+              VM Interface
+            </button>
+          </li>
+        </ul>
+      </div>
+    </div>
+    <div class="row mb-3">
+      <div class="tab-content p-0 border-0">
+        <div class="tab-pane {% if not form.initial.interface or form.initial.vminterface %}active{% endif %}" id="vlan" role="tabpanel" aria-labeled-by="vlan_tab">
+          {% render_field form.device %}
+          {% render_field form.vlan %}
+        </div>
+        <div class="tab-pane {% if form.initial.interface %}active{% endif %}" id="interface" role="tabpanel" aria-labeled-by="interface_tab">
+          {% render_field form.device %}
+          {% render_field form.interface %}
+        </div>
+        <div class="tab-pane {% if form.initial.vminterface %}active{% endif %}" id="vminterface" role="tabpanel" aria-labeled-by="vminterface_tab">
+          {% render_field form.virtual_machine %}
+          {% render_field form.vminterface %}
+        </div>
+      </div>
+    </div>
+  </div>
+{% endblock %}

+ 2 - 2
netbox/templates/ipam/prefix/ip_addresses.html

@@ -23,12 +23,12 @@
 
 
     <div class="noprint bulk-buttons">
     <div class="noprint bulk-buttons">
       <div class="bulk-button-group">
       <div class="bulk-button-group">
-        {% if perms.ipam.change_ipaddress %}
+        {% if 'bulk_edit' in actions %}
           <button type="submit" name="_edit" formaction="{% url 'ipam:ipaddress_bulk_edit' %}?return_url={% url 'ipam:prefix_ipaddresses' pk=object.pk %}" class="btn btn-warning btn-sm">
           <button type="submit" name="_edit" formaction="{% url 'ipam:ipaddress_bulk_edit' %}?return_url={% url 'ipam:prefix_ipaddresses' pk=object.pk %}" class="btn btn-warning btn-sm">
             <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
             <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
           </button>
           </button>
         {% endif %}
         {% endif %}
-        {% if perms.ipam.delete_ipaddress %}
+        {% if 'bulk_delete' in actions %}
           <button type="submit" name="_delete" formaction="{% url 'ipam:ipaddress_bulk_delete' %}?return_url={% url 'ipam:prefix_ipaddresses' pk=object.pk %}" class="btn btn-danger btn-sm">
           <button type="submit" name="_delete" formaction="{% url 'ipam:ipaddress_bulk_delete' %}?return_url={% url 'ipam:prefix_ipaddresses' pk=object.pk %}" class="btn btn-danger btn-sm">
             <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
             <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
           </button>
           </button>

+ 2 - 2
netbox/templates/ipam/prefix/ip_ranges.html

@@ -23,12 +23,12 @@
 
 
     <div class="noprint bulk-buttons">
     <div class="noprint bulk-buttons">
       <div class="bulk-button-group">
       <div class="bulk-button-group">
-        {% if perms.ipam.change_iprange %}
+        {% if 'bulk_edit' in actions %}
           <button type="submit" name="_edit" formaction="{% url 'ipam:iprange_bulk_edit' %}?return_url={% url 'ipam:prefix_ipranges' pk=object.pk %}" class="btn btn-warning btn-sm">
           <button type="submit" name="_edit" formaction="{% url 'ipam:iprange_bulk_edit' %}?return_url={% url 'ipam:prefix_ipranges' pk=object.pk %}" class="btn btn-warning btn-sm">
             <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
             <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
           </button>
           </button>
         {% endif %}
         {% endif %}
-        {% if perms.ipam.delete_iprange %}
+        {% if 'bulk_delete' in actions %}
           <button type="submit" name="_delete" formaction="{% url 'ipam:iprange_bulk_delete' %}?return_url={% url 'ipam:prefix_ipranges' pk=object.pk %}" class="btn btn-danger btn-sm">
           <button type="submit" name="_delete" formaction="{% url 'ipam:iprange_bulk_delete' %}?return_url={% url 'ipam:prefix_ipranges' pk=object.pk %}" class="btn btn-danger btn-sm">
             <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
             <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
           </button>
           </button>

+ 2 - 2
netbox/templates/ipam/prefix/prefixes.html

@@ -25,12 +25,12 @@
 
 
     <div class="noprint bulk-buttons">
     <div class="noprint bulk-buttons">
       <div class="bulk-button-group">
       <div class="bulk-button-group">
-        {% if perms.ipam.change_prefix %}
+        {% if 'bulk_edit' in actions %}
           <button type="submit" name="_edit" formaction="{% url 'ipam:prefix_bulk_edit' %}?return_url={% url 'ipam:prefix_prefixes' pk=object.pk %}" class="btn btn-warning btn-sm">
           <button type="submit" name="_edit" formaction="{% url 'ipam:prefix_bulk_edit' %}?return_url={% url 'ipam:prefix_prefixes' pk=object.pk %}" class="btn btn-warning btn-sm">
             <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
             <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
           </button>
           </button>
         {% endif %}
         {% endif %}
-        {% if perms.ipam.delete_prefix %}
+        {% if 'bulk_delete' in actions %}
           <button type="submit" name="_delete" formaction="{% url 'ipam:prefix_bulk_delete' %}?return_url={% url 'ipam:prefix_prefixes' pk=object.pk %}" class="btn btn-danger btn-sm">
           <button type="submit" name="_delete" formaction="{% url 'ipam:prefix_bulk_delete' %}?return_url={% url 'ipam:prefix_prefixes' pk=object.pk %}" class="btn btn-danger btn-sm">
             <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
             <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
           </button>
           </button>

+ 4 - 0
netbox/templates/ipam/vlan.html

@@ -64,6 +64,10 @@
                             <th scope="row">Description</th>
                             <th scope="row">Description</th>
                             <td>{{ object.description|placeholder }}</td>
                             <td>{{ object.description|placeholder }}</td>
                         </tr>
                         </tr>
+                        <tr>
+                          <th scope="row">L2VPN</th>
+                          <td>{{ object.l2vpn_termination.l2vpn|linkify|placeholder }}</td>
+                        </tr>
                     </table>
                     </table>
                 </div>
                 </div>
             </div>
             </div>

+ 2 - 2
netbox/templates/virtualization/cluster/virtual_machines.html

@@ -14,12 +14,12 @@
     </div>
     </div>
     <div class="noprint bulk-buttons">
     <div class="noprint bulk-buttons">
       <div class="bulk-button-group">
       <div class="bulk-button-group">
-        {% if perms.virtualization.change_virtualmachine %}
+        {% if 'bulk_edit' in actions %}
           <button type="submit" name="_edit" formaction="{% url 'virtualization:virtualmachine_bulk_edit' %}?return_url={% url 'virtualization:cluster_virtualmachines' pk=object.pk %}" class="btn btn-warning btn-sm">
           <button type="submit" name="_edit" formaction="{% url 'virtualization:virtualmachine_bulk_edit' %}?return_url={% url 'virtualization:cluster_virtualmachines' pk=object.pk %}" class="btn btn-warning btn-sm">
             <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
             <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
           </button>
           </button>
         {% endif %}
         {% endif %}
-        {% if perms.virtualization.delete_virtualmachine %}
+        {% if 'bulk_delete' in actions %}
           <button type="submit" name="_delete" formaction="{% url 'virtualization:virtualmachine_bulk_delete' %}?return_url={% url 'virtualization:cluster_virtualmachines' pk=object.pk %}" class="btn btn-danger btn-sm">
           <button type="submit" name="_delete" formaction="{% url 'virtualization:virtualmachine_bulk_delete' %}?return_url={% url 'virtualization:cluster_virtualmachines' pk=object.pk %}" class="btn btn-danger btn-sm">
             <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
             <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
           </button>
           </button>

+ 6 - 3
netbox/users/admin/forms.py

@@ -3,11 +3,11 @@ from django.contrib.auth.models import Group, User
 from django.contrib.admin.widgets import FilteredSelectMultiple
 from django.contrib.admin.widgets import FilteredSelectMultiple
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import FieldError, ValidationError
 from django.core.exceptions import FieldError, ValidationError
-from django.db.models import Q
 
 
-from users.constants import OBJECTPERMISSION_OBJECT_TYPES
+from users.constants import CONSTRAINT_TOKEN_USER, OBJECTPERMISSION_OBJECT_TYPES
 from users.models import ObjectPermission, Token
 from users.models import ObjectPermission, Token
 from utilities.forms.fields import ContentTypeMultipleChoiceField
 from utilities.forms.fields import ContentTypeMultipleChoiceField
+from utilities.permissions import qs_filter_from_constraints
 
 
 __all__ = (
 __all__ = (
     'GroupAdminForm',
     'GroupAdminForm',
@@ -125,7 +125,10 @@ class ObjectPermissionForm(forms.ModelForm):
             for ct in object_types:
             for ct in object_types:
                 model = ct.model_class()
                 model = ct.model_class()
                 try:
                 try:
-                    model.objects.filter(*[Q(**c) for c in constraints]).exists()
+                    tokens = {
+                        CONSTRAINT_TOKEN_USER: 0,  # Replace token with a null user ID
+                    }
+                    model.objects.filter(qs_filter_from_constraints(constraints, tokens)).exists()
                 except FieldError as e:
                 except FieldError as e:
                     raise ValidationError({
                     raise ValidationError({
                         'constraints': f'Invalid filter for {model}: {e}'
                         'constraints': f'Invalid filter for {model}: {e}'

+ 2 - 0
netbox/users/constants.py

@@ -6,3 +6,5 @@ OBJECTPERMISSION_OBJECT_TYPES = Q(
     Q(app_label='auth', model__in=['group', 'user']) |
     Q(app_label='auth', model__in=['group', 'user']) |
     Q(app_label='users', model__in=['objectpermission', 'token'])
     Q(app_label='users', model__in=['objectpermission', 'token'])
 )
 )
+
+CONSTRAINT_TOKEN_USER = '$user'

+ 16 - 1
netbox/utilities/exceptions.py

@@ -1,6 +1,13 @@
 from rest_framework import status
 from rest_framework import status
 from rest_framework.exceptions import APIException
 from rest_framework.exceptions import APIException
 
 
+__all__ = (
+    'AbortRequest',
+    'AbortTransaction',
+    'PermissionsViolation',
+    'RQWorkerNotRunningException',
+)
+
 
 
 class AbortTransaction(Exception):
 class AbortTransaction(Exception):
     """
     """
@@ -9,12 +16,20 @@ class AbortTransaction(Exception):
     pass
     pass
 
 
 
 
+class AbortRequest(Exception):
+    """
+    Raised to cleanly abort a request (for example, by a pre_save signal receiver).
+    """
+    def __init__(self, message):
+        self.message = message
+
+
 class PermissionsViolation(Exception):
 class PermissionsViolation(Exception):
     """
     """
     Raised when an operation was prevented because it would violate the
     Raised when an operation was prevented because it would violate the
     allowed permissions.
     allowed permissions.
     """
     """
-    pass
+    message = "Operation failed due to object-level permissions violation"
 
 
 
 
 class RQWorkerNotRunningException(APIException):
 class RQWorkerNotRunningException(APIException):

+ 7 - 0
netbox/utilities/management/commands/__init__.py

@@ -1,6 +1,8 @@
 from django.db import models
 from django.db import models
 from timezone_field import TimeZoneField
 from timezone_field import TimeZoneField
 
 
+from netbox.config import ConfigItem
+
 
 
 SKIP_FIELDS = (
 SKIP_FIELDS = (
     TimeZoneField,
     TimeZoneField,
@@ -26,4 +28,9 @@ def custom_deconstruct(field):
         for attr in EXEMPT_ATTRS:
         for attr in EXEMPT_ATTRS:
             kwargs.pop(attr, None)
             kwargs.pop(attr, None)
 
 
+    # Ignore any field defaults which reference a ConfigItem
+    kwargs = {
+        k: v for k, v in kwargs.items() if not isinstance(v, ConfigItem)
+    }
+
     return name, path, args, kwargs
     return name, path, args, kwargs

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