Răsfoiți Sursa

Merge branch 'feature' into 9102-cabling

jeremystretch 3 ani în urmă
părinte
comite
ba079b9ee5
100 a modificat fișierele cu 2239 adăugiri și 323 ștergeri
  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.
+
+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!}
 
-### Example Constraint Definitions
+#### Example Constraint Definitions
 
 | 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
 
 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
 
 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
 
 Default: False

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

@@ -26,3 +26,8 @@
 ---
 
 {!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.IPAddress](../models/ipam/ipaddress.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.RouteTarget](../models/ipam/routetarget.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.
+
+### 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.customfield_value
+
 ::: utilities.templatetags.builtins.tags.tag
 
 ## 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.
 
-| 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
     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:
         - get_object
 
+::: netbox.views.generic.ObjectChildrenView
+    selection:
+      members:
+        - get_children
+        - prep_table_data
+
 ## 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.

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

@@ -2,6 +2,19 @@
 
 ## 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)

+ 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))
 
+#### 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))
 
+#### Reference User in Permission Constraints ([#9074](https://github.com/netbox-community/netbox/issues/9074))
+
 ### Enhancements
 
 * [#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
 * [#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
+* [#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
 * [#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
+* [#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
+* [#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
 * [#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
@@ -34,7 +41,11 @@
 
 ### 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
+* [#9647](https://github.com/netbox-community/netbox/issues/9647) - Introduce `customfield_value` template tag
 
 ### Other Changes
 
@@ -43,14 +54,20 @@
 
 ### REST API Changes
 
+* Added the following endpoints:
+    * `/api/ipam/l2vpns/`
+    * `/api/ipam/l2vpn-terminations/`
 * circuits.Circuit
     * Added optional `termination_date` field
+* circuits.CircuitTermination
+    * Added 'custom_fields' and 'tags' fields
 * dcim.Device
     * The `position` field has been changed from an integer to a decimal
 * dcim.DeviceType
     * The `u_height` field has been changed from an integer to a decimal
 * dcim.Interface
     * Added the optional `poe_mode` and `poe_type` fields
+    * Added the `l2vpn_termination` read-only field
 * dcim.Location
     * Added required `status` field (default value: `active`)
 * dcim.Rack
@@ -62,15 +79,18 @@
 * ipam.IPAddress
     * The `nat_inside` field no longer requires a unique value
     * The `nat_outside` field has changed from a single IP address instance to a list of multiple IP addresses
+* ipam.VLAN
+    * Added the `l2vpn_termination` read-only field
 * users.Token
     * Added the `allowed_ips` array field
     * Added the read-only `last_used` datetime field
 * virtualization.Cluster
     * Added required `status` field (default value: `active`)
 * virtualization.VirtualMachine
-    * Added `device` field
     * The `site` field is now directly writable (rather than being inferred from the assigned cluster)
     * The `cluster` field is now optional. A virtual machine must have a site and/or cluster assigned.
+    * Added the `device` field
+    * Added the `l2vpn_termination` read-only field
 wireless.WirelessLAN
     * Added `tenant` field
 wireless.WirelessLink

+ 1 - 0
mkdocs.yml

@@ -118,6 +118,7 @@ nav:
             - REST API: 'plugins/development/rest-api.md'
             - GraphQL API: 'plugins/development/graphql-api.md'
             - Background Tasks: 'plugins/development/background-tasks.md'
+            - Exceptions: 'plugins/development/exceptions.md'
     - Administration:
         - Authentication:
             - 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')
     circuit = NestedCircuitSerializer()
     site = NestedSiteSerializer(required=False, allow_null=True)
@@ -110,5 +110,5 @@ class CircuitTerminationSerializer(ValidatedModelSerializer, LinkTerminationSeri
         fields = [
             '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',
-            '_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()
 
 
-class CircuitTerminationFilterSet(ChangeLoggedModelFilterSet, CabledObjectFilterSet):
+class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
     q = django_filters.CharFilter(
         method='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(
         queryset=Provider.objects.all(),
         required=False,
@@ -161,7 +161,7 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
         model = CircuitTermination
         fields = [
             '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 = {
             'port_speed': "Physical circuit speed",

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

@@ -1,4 +1,5 @@
 from circuits import filtersets, models
+from extras.graphql.mixins import CustomFieldsMixin, TagsMixin
 from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType
 
 __all__ = (
@@ -10,7 +11,7 @@ __all__ = (
 )
 
 
-class CircuitTerminationType(ObjectType):
+class CircuitTerminationType(CustomFieldsMixin, TagsMixin, ObjectType):
 
     class Meta:
         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):
 
     dependencies = [
-        ('circuits', '0036_circuit_termination_date'),
+        ('circuits', '0036_circuit_termination_date_tags_custom_fields'),
     ]
 
     operations = [

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

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

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

@@ -5,6 +5,7 @@ from netbox.api.serializers import BaseModelSerializer, WritableNestedSerializer
 
 __all__ = [
     'ComponentNestedModuleSerializer',
+    'ModuleBayNestedModuleSerializer',
     'NestedCableSerializer',
     'NestedConsolePortSerializer',
     'NestedConsolePortTemplateSerializer',
@@ -281,6 +282,14 @@ class ModuleNestedModuleBaySerializer(WritableNestedSerializer):
         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):
     """
     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 extras.api.serializers import ContentTypeSerializer
 from ipam.api.nested_serializers import (
-    NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer,
+    NestedASNSerializer, NestedIPAddressSerializer, NestedL2VPNTerminationSerializer, NestedVLANSerializer,
+    NestedVRFSerializer,
 )
 from ipam.models import ASN, VLAN
 from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
@@ -868,6 +869,7 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
         many=True
     )
     vrf = NestedVRFSerializer(required=False, allow_null=True)
+    l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True)
     cable = NestedCableSerializer(read_only=True)
     wireless_link = NestedWirelessLinkSerializer(read_only=True)
     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',
             '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',
-            '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):
@@ -957,12 +960,12 @@ class FrontPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
 class ModuleBaySerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail')
     device = NestedDeviceSerializer()
-    # installed_module = NestedModuleSerializer(required=False, allow_null=True)
+    installed_module = ModuleBayNestedModuleSerializer(required=False, allow_null=True)
 
     class Meta:
         model = ModuleBay
         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',
         ]
 

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

@@ -621,7 +621,7 @@ class RearPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
 
 
 class ModuleBayViewSet(NetBoxModelViewSet):
-    queryset = ModuleBay.objects.prefetch_related('tags')
+    queryset = ModuleBay.objects.prefetch_related('tags', 'installed_module')
     serializer_class = serializers.ModuleBaySerializer
     filterset_class = filtersets.ModuleBayFilterSet
     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
 
 
-#
-# Power feeds
-#
-
-POWERFEED_VOLTAGE_DEFAULT = 120
-POWERFEED_AMPERAGE_DEFAULT = 20
-POWERFEED_MAX_UTILIZATION_DEFAULT = 80  # Percentage
-
-
 #
 # Device components
 #

+ 6 - 0
netbox/dcim/filtersets.py

@@ -997,6 +997,12 @@ class ModuleFilterSet(NetBoxModelFilterSet):
         to_field_name='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(
         queryset=Device.objects.all(),
         label='Device (ID)',

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

@@ -525,13 +525,28 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
         required=False,
         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:
         model = Device
         fields = [
             'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'rack',
             '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 = {
             '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)),
                 ('supply', models.CharField(default='ac', 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)),
                 ('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',
         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']
 
@@ -822,6 +828,10 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
     def link(self):
         return self.cable or self.wireless_link
 
+    @property
+    def l2vpn_termination(self):
+        return self.l2vpn_terminations.first()
+
 
 #
 # 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.constants import *
+from netbox.config import ConfigItem
 from netbox.models import NetBoxModel
 from utilities.validators import ExclusionValidator
 from .device_components import LinkTermination, PathEndpoint
@@ -105,16 +106,16 @@ class PowerFeed(NetBoxModel, PathEndpoint, LinkTermination):
         default=PowerFeedPhaseChoices.PHASE_SINGLE
     )
     voltage = models.SmallIntegerField(
-        default=POWERFEED_VOLTAGE_DEFAULT,
+        default=ConfigItem('POWERFEED_DEFAULT_VOLTAGE'),
         validators=[ExclusionValidator([0])]
     )
     amperage = models.PositiveSmallIntegerField(
         validators=[MinValueValidator(1)],
-        default=POWERFEED_AMPERAGE_DEFAULT
+        default=ConfigItem('POWERFEED_DEFAULT_AMPERAGE')
     )
     max_utilization = models.PositiveSmallIntegerField(
         validators=[MinValueValidator(1), MaxValueValidator(100)],
-        default=POWERFEED_MAX_UTILIZATION_DEFAULT,
+        default=ConfigItem('POWERFEED_DEFAULT_MAX_UTILIZATION'),
         help_text="Maximum permissible draw (percentage)"
     )
     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]}
         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):
         device_types = Device.objects.all()[:2]
         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', {
             'fields': ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH'),
         }),
+        ('Power', {
+            'fields': ('POWERFEED_DEFAULT_VOLTAGE', 'POWERFEED_DEFAULT_AMPERAGE', 'POWERFEED_DEFAULT_MAX_UTILIZATION')
+        }),
         ('IPAM', {
             'fields': ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4'),
         }),

+ 10 - 3
netbox/extras/webhooks.py

@@ -1,6 +1,5 @@
 import hashlib
 import hmac
-from collections import defaultdict
 
 from django.contrib.contenttypes.models import ContentType
 from django.utils import timezone
@@ -27,10 +26,18 @@ def serialize_for_webhook(instance):
 
 
 def get_snapshots(instance, action):
-    return {
+    snapshots = {
         '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):

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

@@ -1,6 +1,7 @@
 from rest_framework import serializers
 
 from ipam import models
+from ipam.models.l2vpn import L2VPNTermination, L2VPN
 from netbox.api import WritableNestedSerializer
 
 __all__ = [
@@ -10,6 +11,8 @@ __all__ = [
     'NestedFHRPGroupAssignmentSerializer',
     'NestedIPAddressSerializer',
     'NestedIPRangeSerializer',
+    'NestedL2VPNSerializer',
+    'NestedL2VPNTerminationSerializer',
     'NestedPrefixSerializer',
     'NestedRIRSerializer',
     'NestedRoleSerializer',
@@ -190,3 +193,28 @@ class NestedServiceSerializer(WritableNestedSerializer):
     class Meta:
         model = models.Service
         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
 #
+from .nested_serializers import NestedL2VPNSerializer
+from ..models.l2vpn import L2VPNTermination, L2VPN
+
 
 class ASNSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail')
@@ -204,13 +207,14 @@ class VLANSerializer(NetBoxModelSerializer):
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     status = ChoiceField(choices=VLANStatusChoices, required=False)
     role = NestedRoleSerializer(required=False, allow_null=True)
+    l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True)
     prefix_count = serializers.IntegerField(read_only=True)
 
     class Meta:
         model = VLAN
         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',
             '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('services', views.ServiceViewSet)
 
+# L2VPN
+router.register('l2vpns', views.L2VPNViewSet)
+router.register('l2vpn-terminations', views.L2VPNTerminationViewSet)
+
 app_name = 'ipam-api'
 
 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.utils import count_related
 from . import serializers
+from ipam.models import L2VPN, L2VPNTermination
 
 
 class IPAMRootView(APIRootView):
@@ -157,6 +158,18 @@ class ServiceViewSet(NetBoxModelViewSet):
     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
 #

+ 49 - 0
netbox/ipam/choices.py

@@ -170,3 +170,52 @@ class ServiceProtocolChoices(ChoiceSet):
         (PROTOCOL_UDP, 'UDP'),
         (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
 SERVICE_PORT_MIN = 1
 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',
     'IPAddressFilterSet',
     'IPRangeFilterSet',
+    'L2VPNFilterSet',
+    'L2VPNTerminationFilterSet',
     'PrefixFilterSet',
     'RIRFilterSet',
     'RoleFilterSet',
@@ -922,3 +924,113 @@ class ServiceFilterSet(NetBoxModelFilterSet):
             return queryset
         qs_filter = Q(name__icontains=value) | Q(description__icontains=value)
         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',
     'IPAddressBulkEditForm',
     'IPRangeBulkEditForm',
+    'L2VPNBulkEditForm',
+    'L2VPNTerminationBulkEditForm',
     'PrefixBulkEditForm',
     'RIRBulkEditForm',
     'RoleBulkEditForm',
@@ -440,3 +442,24 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm):
 
 class ServiceBulkEditForm(ServiceTemplateBulkEditForm):
     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.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ValidationError
 
 from dcim.models import Device, Interface, Site
 from ipam.choices import *
@@ -16,6 +17,8 @@ __all__ = (
     'FHRPGroupCSVForm',
     'IPAddressCSVForm',
     'IPRangeCSVForm',
+    'L2VPNCSVForm',
+    'L2VPNTerminationCSVForm',
     'PrefixCSVForm',
     'RIRCSVForm',
     'RoleCSVForm',
@@ -425,3 +428,83 @@ class ServiceCSVForm(NetBoxModelCSVForm):
     class Meta:
         model = Service
         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.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.constants import *
 from ipam.models import *
@@ -19,6 +20,8 @@ __all__ = (
     'FHRPGroupFilterForm',
     'IPAddressFilterForm',
     'IPRangeFilterForm',
+    'L2VPNFilterForm',
+    'L2VPNTerminationFilterForm',
     'PrefixFilterForm',
     'RIRFilterForm',
     'RoleFilterForm',
@@ -265,6 +268,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
         ('Attributes', ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface')),
         ('VRF', ('vrf_id', 'present_in_vrf_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
+        ('Device/VM', ('device_id', 'virtual_machine_id')),
     )
     parent = forms.CharField(
         required=False,
@@ -298,6 +302,16 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
         required=False,
         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(
         choices=IPAddressStatusChoices,
         required=False
@@ -463,3 +477,30 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm):
 
 class ServiceFilterForm(ServiceTemplateFilterForm):
     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.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ValidationError
 
 from dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup
 from extras.models import Tag
@@ -7,9 +8,9 @@ from ipam.choices import *
 from ipam.constants import *
 from ipam.formfields import IPNetworkFormField
 from ipam.models import *
-from ipam.models import ASN
 from netbox.forms import NetBoxModelForm
 from tenancy.forms import TenancyForm
+from tenancy.models import Tenant
 from utilities.exceptions import PermissionsViolation
 from utilities.forms import (
     add_blank_choice, BootstrapMixin, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField,
@@ -26,6 +27,8 @@ __all__ = (
     'IPAddressBulkAddForm',
     'IPAddressForm',
     'IPRangeForm',
+    'L2VPNForm',
+    'L2VPNTerminationForm',
     'PrefixForm',
     'RIRForm',
     'RoleForm',
@@ -861,3 +864,110 @@ class ServiceCreateForm(ServiceForm):
                 self.cleaned_data['description'] = service_template.description
         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.")
+
+
+#
+# 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_list = ObjectListField(IPRangeType)
 
+    l2vpn = ObjectField(L2VPNType)
+    l2vpn_list = ObjectListField(L2VPNType)
+
+    l2vpn_termination = ObjectField(L2VPNTerminationType)
+    l2vpn_termination_list = ObjectListField(L2VPNTerminationType)
+
     prefix = ObjectField(PrefixType)
     prefix_list = ObjectListField(PrefixType)
 

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

@@ -11,6 +11,8 @@ __all__ = (
     'FHRPGroupAssignmentType',
     'IPAddressType',
     'IPRangeType',
+    'L2VPNType',
+    'L2VPNTerminationType',
     'PrefixType',
     'RIRType',
     'RoleType',
@@ -151,3 +153,17 @@ class VRFType(NetBoxObjectType):
         model = models.VRF
         fields = '__all__'
         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 .vrfs import *
 from .ip import *
+from .l2vpn import *
 from .services import *
 from .vlans import *
 
@@ -12,6 +13,8 @@ __all__ = (
     'IPRange',
     'FHRPGroup',
     'FHRPGroupAssignment',
+    'L2VPN',
+    'L2VPNTermination',
     'Prefix',
     'RIR',
     'Role',

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

@@ -857,6 +857,25 @@ class IPAddress(NetBoxModel):
             address__net_host=str(self.address.ip)
         ).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):
         super().clean()
 
@@ -907,6 +926,15 @@ class IPAddress(NetBoxModel):
 
         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):
         objectchange = super().to_objectchange(action)
         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.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
@@ -8,6 +8,7 @@ from django.urls import reverse
 from dcim.models import Interface
 from ipam.choices import *
 from ipam.constants import *
+from ipam.models import L2VPNTermination
 from ipam.querysets import VLANQuerySet
 from netbox.models import OrganizationalModel, NetBoxModel
 from virtualization.models import VMInterface
@@ -173,6 +174,13 @@ class VLAN(NetBoxModel):
         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()
 
     clone_fields = [
@@ -227,3 +235,7 @@ class VLAN(NetBoxModel):
             Q(untagged_vlan_id=self.pk) |
             Q(tagged_vlans=self.pk)
         ).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 .ip import *
+from .l2vpn import *
 from .services import *
 from .vlans 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],
             },
         ]
+
+
+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)
         params = {'virtual_machine': [vms[0].name, vms[1].name]}
         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.test import TestCase, override_settings
 
+from dcim.models import Interface, Device, DeviceRole, DeviceType, Manufacturer, Site
 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):
@@ -538,3 +539,76 @@ class TestVLANGroup(TestCase):
 
         VLAN.objects.create(name='VLAN 104', vid=104, group=vlangroup)
         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
 
+from django.contrib.contenttypes.models import ContentType
 from django.test import override_settings
 from django.urls import reverse
 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.models import *
 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):
@@ -746,3 +750,133 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         self.assertEqual(instance.protocol, service_template.protocol)
         self.assertEqual(instance.ports, service_template.ports)
         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>/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 .models import *
 from .models import ASN
+from .tables.l2vpn import L2VPNTable, L2VPNTerminationTable
 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)
 
         # 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)
 
@@ -1147,3 +1151,105 @@ class ServiceBulkDeleteView(generic.BulkDeleteView):
     queryset = Service.objects.prefetch_related('device', 'virtual_machine')
     filterset = filtersets.ServiceFilterSet
     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 netbox.api.exceptions import SerializerNotFound
 from utilities.api import get_serializer_for_model
+from utilities.exceptions import AbortRequest
 from .mixins import *
 
 __all__ = (
@@ -125,6 +126,14 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali
                 *args,
                 **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):
         """

+ 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.db.models import Q
 
+from users.constants import CONSTRAINT_TOKEN_USER
 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()
 
@@ -99,8 +102,10 @@ class ObjectPermissionMixin:
         if not user_obj.is_active or user_obj.is_anonymous:
             return False
 
+        object_permissions = self.get_all_permissions(user_obj)
+
         # 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
 
         # 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)):
             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
         # the specified constraints. Note that this check is made against the *database* record representing the object,
         # 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):
@@ -348,3 +348,26 @@ class LDAPBackend:
             ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
 
         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
     ),
 
+    # 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
     ConfigParam(
         name='ALLOWED_URL_SCHEMES',

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

@@ -49,11 +49,19 @@ class ChangeLoggingMixin(models.Model):
     class Meta:
         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):
         """
-        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):
         """
@@ -69,7 +77,7 @@ class ChangeLoggingMixin(models.Model):
         if hasattr(self, '_prechange_snapshot'):
             objectchange.prechange_data = self._prechange_snapshot
         if action in (ObjectChangeActionChoices.ACTION_CREATE, ObjectChangeActionChoices.ACTION_UPDATE):
-            objectchange.postchange_data = serialize_object(self)
+            objectchange.postchange_data = self.serialize_object()
 
         return objectchange
 

+ 7 - 0
netbox/netbox/navigation_menu.py

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

+ 13 - 0
netbox/netbox/settings.py

@@ -485,6 +485,19 @@ for param in dir(configuration):
 
 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

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

@@ -1,6 +1,5 @@
 import logging
 import re
-from collections import defaultdict
 from copy import deepcopy
 
 from django.contrib import messages
@@ -12,11 +11,12 @@ from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput
 from django.http import HttpResponse
 from django.shortcuts import get_object_or_404, redirect, render
 from django_tables2.export import TableExport
+from django.utils.safestring import mark_safe
 
 from extras.models import ExportTemplate
 from extras.signals import clear_webhooks
 from utilities.error_handlers import handle_protectederror
-from utilities.exceptions import PermissionsViolation
+from utilities.exceptions import AbortRequest, PermissionsViolation
 from utilities.forms import (
     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.views import GetReturnURLMixin
 from .base import BaseMultiObjectView
+from .mixins import ActionsMixin, TableMixin
 
 __all__ = (
     '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:
         filterset: A django-filter FilterSet that is applied to the queryset
@@ -50,31 +51,10 @@ class ObjectListView(BaseMultiObjectView):
     template_name = 'generic/object_list.html'
     filterset = 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):
         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
     #
@@ -147,19 +127,14 @@ class ObjectListView(BaseMultiObjectView):
             self.queryset = self.filterset(request.GET, self.queryset).qs
 
         # 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])
 
         if 'export' in request.GET:
 
             # Export the current table view
             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]
                 return self.export_table(table, columns)
 
@@ -177,12 +152,11 @@ class ObjectListView(BaseMultiObjectView):
 
             # Fall back to default table/YAML export
             else:
-                table = self.get_table(request, has_bulk_actions)
+                table = self.get_table(self.queryset, request, has_bulk_actions)
                 return self.export_table(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 is_htmx(request):
@@ -190,15 +164,13 @@ class ObjectListView(BaseMultiObjectView):
                 'table': table,
             })
 
-        context = {
+        return render(request, self.template_name, {
             'model': model,
             'table': table,
             'actions': actions,
             'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None,
             **self.get_extra_context(request),
-        }
-
-        return render(request, self.template_name, context)
+        })
 
 
 class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
@@ -292,10 +264,10 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
             except IntegrityError:
                 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:
             logger.debug("Form validation failed")
@@ -420,10 +392,9 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
             except ValidationError:
                 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)
 
         else:
@@ -570,10 +541,9 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
                     messages.error(self.request, ", ".join(e.messages))
                     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)
 
             else:
@@ -667,10 +637,9 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
                             messages.success(request, f"Renamed {len(selected_objects)} {model_name}")
                             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)
 
         else:
@@ -745,11 +714,17 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView):
                         if hasattr(obj, 'snapshot'):
                             obj.snapshot()
                         obj.delete()
+
                 except ProtectedError as e:
                     logger.info("Caught ProtectedError while attempting to delete objects")
                     handle_protectederror(queryset, request, e)
                     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}"
                 logger.info(msg)
                 messages.success(request, msg)
@@ -857,10 +832,9 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView):
                 except IntegrityError:
                     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)
 
                 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 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.htmx import is_htmx
 from utilities.permissions import get_permission_for_model
 from utilities.utils import get_viewname, normalize_querydict, prepare_cloned_fields
 from utilities.views import GetReturnURLMixin
 from .base import BaseObjectView
+from .mixins import ActionsMixin, TableMixin
 
 __all__ = (
     '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.
 
     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
     table = None
@@ -84,8 +90,9 @@ class ObjectChildrenView(ObjectView):
         """
         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()')
 
@@ -114,16 +121,11 @@ class ObjectChildrenView(ObjectView):
         if self.filterset:
             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 is_htmx(request):
@@ -134,8 +136,9 @@ class ObjectChildrenView(ObjectView):
 
         return render(request, self.get_template_name(), {
             'object': instance,
+            'child_model': self.child_model,
             'table': table,
-            'permissions': permissions,
+            'actions': actions,
             **self.get_extra_context(request, instance),
         })
 
@@ -243,10 +246,9 @@ class ObjectImportView(GetReturnURLMixin, BaseObjectView):
                 except AbortTransaction:
                     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)
 
             if not model_form.errors:
@@ -407,10 +409,9 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
 
                 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)
 
         else:
@@ -486,11 +487,17 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView):
 
             try:
                 obj.delete()
+
             except ProtectedError as e:
                 logger.info("Caught ProtectedError while attempting to delete object")
                 handle_protectederror([obj], request, e)
                 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)
             logger.info(msg)
             messages.success(request, msg)
@@ -600,10 +607,9 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
                         else:
                             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)
 
         return render(request, self.template_name, {

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

@@ -8,74 +8,78 @@
 {% endblock %}
 
 {% 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>
-        {% 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 class="row">
+  <div class="row">
     <div class="col col-md-12">
-        {% plugin_full_width_page object %}
+      {% plugin_full_width_page object %}
     </div>
-</div>
+  </div>
 {% endblock %}

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

@@ -10,6 +10,7 @@
     {% render_field form.provider %}
     {% render_field form.circuit %}
     {% render_field form.term_side %}
+    {% render_field form.tags %}
     {% render_field form.mark_connected %}
     {% with providernetwork_tab_active=form.initial.provider_network %}
       <div class="row mb-2">
@@ -47,6 +48,13 @@
     {% render_field form.pp_info %}
     {% render_field form.description %}
   </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 %}
 
 {# 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-header">
-        <strong class="d-block d-md-inline mb-3 mb-md-0">Termination - {{ side }} Side</strong>
         <div class="float-md-end">
             {% 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">
@@ -10,10 +9,10 @@
                 </a>
             {% endif %}
             {% 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
                 </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
                 </a>
             {% endif %}
@@ -23,6 +22,7 @@
                 </a>
             {% endif %}
         </div>
+        <h5>Termination {{ side }}</h5>
     </div>
     <div class="card-body">
       {% if termination %}
@@ -109,6 +109,33 @@
                 <td>Description</td>
                 <td>{{ termination.description|placeholder }}</td>
             </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>
     {% else %}
         <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="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">
                     <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
                 </button>
@@ -28,7 +28,7 @@
                     <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
                 </button>
             {% 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">
                     <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
                 </button>

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

@@ -17,7 +17,7 @@
 
     <div class="noprint bulk-buttons">
         <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">
                     <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
                 </button>
@@ -28,7 +28,7 @@
                     <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
                 </button>
             {% 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">
                     <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
                 </button>

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

@@ -17,7 +17,7 @@
 
     <div class="noprint bulk-buttons">
         <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">
                     <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
                 </button>
@@ -25,7 +25,7 @@
                     <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
                 </button>
             {% 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">
                     <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete selected
                 </button>

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

@@ -17,7 +17,7 @@
 
     <div class="noprint bulk-buttons">
         <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">
                     <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
                 </button>
@@ -28,7 +28,7 @@
                     <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
                 </button>
             {% 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">
                     <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
                 </button>

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

@@ -53,7 +53,7 @@
 
     <div class="noprint bulk-buttons">
         <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">
                 <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
             </button>
@@ -64,7 +64,7 @@
                 <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
             </button>
         {% 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">
                 <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
             </button>

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

@@ -17,7 +17,7 @@
 
     <div class="noprint bulk-buttons">
         <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">
                     <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
                 </button>
@@ -25,7 +25,7 @@
                     <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
                 </button>
             {% 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">
                     <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
                 </button>

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

@@ -17,7 +17,7 @@
 
     <div class="noprint bulk-buttons">
         <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">
                     <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
                 </button>
@@ -25,7 +25,7 @@
                     <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
                 </button>
             {% 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">
                     <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete selected
                 </button>

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

@@ -17,7 +17,7 @@
 
     <div class="noprint bulk-buttons">
         <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">
                     <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
                 </button>
@@ -28,7 +28,7 @@
                     <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
                 </button>
             {% 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">
                     <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
                 </button>

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

@@ -17,7 +17,7 @@
 
     <div class="noprint bulk-buttons">
         <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">
                     <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
                 </button>
@@ -28,7 +28,7 @@
                     <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
                 </button>
             {% 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">
                     <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
                 </button>

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

@@ -17,7 +17,7 @@
 
     <div class="noprint bulk-buttons">
         <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">
                     <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
                 </button>
@@ -28,7 +28,7 @@
                     <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
                 </button>
             {% 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">
                     <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
                 </button>

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

@@ -86,6 +86,15 @@
       {% render_field form.tenant %}
     </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 %}
       <div class="field-group my-5">
         <div class="row mb-2">

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

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

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

@@ -72,6 +72,14 @@
         </div>
     {% 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="row mb-2">
           <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>
                 </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>
               </tr>
             {% endfor %}

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

@@ -25,12 +25,12 @@
 
     <div class="noprint bulk-buttons">
       <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">
             <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
           </button>
         {% 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">
             <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
           </button>

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

@@ -23,12 +23,12 @@
 
     <div class="noprint bulk-buttons">
       <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">
             <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
           </button>
         {% 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">
             <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
           </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="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">
             <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
           </button>
         {% 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">
             <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
           </button>

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

@@ -23,12 +23,12 @@
 
     <div class="noprint bulk-buttons">
       <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">
             <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
           </button>
         {% 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">
             <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
           </button>

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

@@ -25,12 +25,12 @@
 
     <div class="noprint bulk-buttons">
       <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">
             <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
           </button>
         {% 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">
             <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
           </button>

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

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

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

@@ -14,12 +14,12 @@
     </div>
     <div class="noprint bulk-buttons">
       <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">
             <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
           </button>
         {% 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">
             <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
           </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.contenttypes.models import ContentType
 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 utilities.forms.fields import ContentTypeMultipleChoiceField
+from utilities.permissions import qs_filter_from_constraints
 
 __all__ = (
     'GroupAdminForm',
@@ -125,7 +125,10 @@ class ObjectPermissionForm(forms.ModelForm):
             for ct in object_types:
                 model = ct.model_class()
                 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:
                     raise ValidationError({
                         '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='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.exceptions import APIException
 
+__all__ = (
+    'AbortRequest',
+    'AbortTransaction',
+    'PermissionsViolation',
+    'RQWorkerNotRunningException',
+)
+
 
 class AbortTransaction(Exception):
     """
@@ -9,12 +16,20 @@ class AbortTransaction(Exception):
     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):
     """
     Raised when an operation was prevented because it would violate the
     allowed permissions.
     """
-    pass
+    message = "Operation failed due to object-level permissions violation"
 
 
 class RQWorkerNotRunningException(APIException):

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

@@ -1,6 +1,8 @@
 from django.db import models
 from timezone_field import TimeZoneField
 
+from netbox.config import ConfigItem
+
 
 SKIP_FIELDS = (
     TimeZoneField,
@@ -26,4 +28,9 @@ def custom_deconstruct(field):
         for attr in EXEMPT_ATTRS:
             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

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff