diff --git a/NOTICE b/NOTICE index e6dc6408a..4e4dd600c 100644 --- a/NOTICE +++ b/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. diff --git a/docs/administration/permissions.md b/docs/administration/permissions.md index f859266af..60717c28a 100644 --- a/docs/administration/permissions.md +++ b/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 | | ----------- | ----------- | diff --git a/docs/configuration/dynamic-settings.md b/docs/configuration/dynamic-settings.md index 2fa046fcf..d376dc5c4 100644 --- a/docs/configuration/dynamic-settings.md +++ b/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 diff --git a/docs/core-functionality/ipam.md b/docs/core-functionality/ipam.md index 01bb3c76d..c86819380 100644 --- a/docs/core-functionality/ipam.md +++ b/docs/core-functionality/ipam.md @@ -26,3 +26,8 @@ --- {!models/ipam/asn.md!} + +--- + +{!models/ipam/l2vpn.md!} +{!models/ipam/l2vpntermination.md!} diff --git a/docs/development/models.md b/docs/development/models.md index ae1bab7e7..b6b2e4da2 100644 --- a/docs/development/models.md +++ b/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) diff --git a/docs/models/ipam/l2vpn.md b/docs/models/ipam/l2vpn.md new file mode 100644 index 000000000..9f9b4703c --- /dev/null +++ b/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. diff --git a/docs/models/ipam/l2vpntermination.md b/docs/models/ipam/l2vpntermination.md new file mode 100644 index 000000000..cc1843639 --- /dev/null +++ b/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. diff --git a/docs/models/users/objectpermission.md b/docs/models/users/objectpermission.md index 48970dd05..075a2cae5 100644 --- a/docs/models/users/objectpermission.md +++ b/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. diff --git a/docs/plugins/development/exceptions.md b/docs/plugins/development/exceptions.md new file mode 100644 index 000000000..80f5db258 --- /dev/null +++ b/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. diff --git a/docs/plugins/development/templates.md b/docs/plugins/development/templates.md index 64616c442..20838149f 100644 --- a/docs/plugins/development/templates.md +++ b/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 diff --git a/docs/plugins/development/views.md b/docs/plugins/development/views.md index 92626f8d3..cabcd7045 100644 --- a/docs/plugins/development/views.md +++ b/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. diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 059fc8924..57d965538 100644 --- a/docs/release-notes/version-3.2.md +++ b/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) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 1e18de1e6..456afd765 100644 --- a/docs/release-notes/version-3.3.md +++ b/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 diff --git a/mkdocs.yml b/mkdocs.yml index 507b25627..88a2794e8 100644 --- a/mkdocs.yml +++ b/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' diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 508f468be..ab72ffcdd 100644 --- a/netbox/circuits/api/serializers.py +++ b/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', ] diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index b8d69cc44..8005c0afe 100644 --- a/netbox/circuits/filtersets.py +++ b/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', diff --git a/netbox/circuits/forms/models.py b/netbox/circuits/forms/models.py index 907c39586..7bd7abbbf 100644 --- a/netbox/circuits/forms/models.py +++ b/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", diff --git a/netbox/circuits/graphql/types.py b/netbox/circuits/graphql/types.py index 027b53203..094b78d07 100644 --- a/netbox/circuits/graphql/types.py +++ b/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 diff --git a/netbox/circuits/migrations/0036_circuit_termination_date.py b/netbox/circuits/migrations/0036_circuit_termination_date.py deleted file mode 100644 index 0a8adfbe6..000000000 --- a/netbox/circuits/migrations/0036_circuit_termination_date.py +++ /dev/null @@ -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), - ), - ] diff --git a/netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py b/netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py new file mode 100644 index 000000000..c686bf042 --- /dev/null +++ b/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'), + ), + ] diff --git a/netbox/circuits/migrations/0037_new_cabling_models.py b/netbox/circuits/migrations/0037_new_cabling_models.py index ddf4744f1..ee08147f3 100644 --- a/netbox/circuits/migrations/0037_new_cabling_models.py +++ b/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 = [ diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index 5df6f1b85..cf6ffc503 100644 --- a/netbox/circuits/models/circuits.py +++ b/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, diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index 0ec0e07e0..1be8bb9dc 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/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. diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index e899062bd..57b32bfc6 100644 --- a/netbox/dcim/api/serializers.py +++ b/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', ] diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 4df89d55d..e6f7605ef 100644 --- a/netbox/dcim/api/views.py +++ b/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'] diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index aa921ccc6..9e41ed113 100644 --- a/netbox/dcim/constants.py +++ b/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 # diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index f13c48423..e30ab4f04 100644 --- a/netbox/dcim/filtersets.py +++ b/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)', diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 7aa2a8584..aa573a4df 100644 --- a/netbox/dcim/forms/models.py +++ b/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", diff --git a/netbox/dcim/migrations/0001_squashed.py b/netbox/dcim/migrations/0001_squashed.py index bb99d199f..374d3bf45 100644 --- a/netbox/dcim/migrations/0001_squashed.py +++ b/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)), ], diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 72be19b10..67af3a7f8 100644 --- a/netbox/dcim/models/device_components.py +++ b/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 diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index 08f89e3b0..5978d86bd 100644 --- a/netbox/dcim/models/power.py +++ b/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( diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 027661556..2d3b57d01 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/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]} diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 28902c323..01011b276 100644 --- a/netbox/extras/admin.py +++ b/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'), }), diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index d8739cb55..334539026 100644 --- a/netbox/extras/webhooks.py +++ b/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): diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index 5f9e09049..e74d60fb2 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/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' + ] diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index ea5c37f91..9cde08374 100644 --- a/netbox/ipam/api/serializers.py +++ b/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 diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index 99e039eff..20e31f4d4 100644 --- a/netbox/ipam/api/urls.py +++ b/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 = [ diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index dcddec580..0407c6d39 100644 --- a/netbox/ipam/api/views.py +++ b/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 # diff --git a/netbox/ipam/choices.py b/netbox/ipam/choices.py index a364d3c6a..298baa643 100644 --- a/netbox/ipam/choices.py +++ b/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 + ) diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index ab88dfc1a..cb121515d 100644 --- a/netbox/ipam/constants.py +++ b/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') +) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index d9cf6eefc..f682009ee 100644 --- a/netbox/ipam/filtersets.py +++ b/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 + ) diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 66b4ba0fc..50fc51522 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/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 diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 17da242a0..b8dd1c54c 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/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') diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index bbd6bb97b..795cfe378 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/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' + ) diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index e86abc672..3986eee32 100644 --- a/netbox/ipam/forms/models.py +++ b/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 diff --git a/netbox/ipam/graphql/schema.py b/netbox/ipam/graphql/schema.py index f466c1857..5cd5e030e 100644 --- a/netbox/ipam/graphql/schema.py +++ b/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) diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index ca206b4b8..5af2ca72a 100644 --- a/netbox/ipam/graphql/types.py +++ b/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 diff --git a/netbox/ipam/migrations/0059_l2vpn.py b/netbox/ipam/migrations/0059_l2vpn.py new file mode 100644 index 000000000..5662049c3 --- /dev/null +++ b/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'), + ), + ] diff --git a/netbox/ipam/models/__init__.py b/netbox/ipam/models/__init__.py index ce09c482a..d13ee9076 100644 --- a/netbox/ipam/models/__init__.py +++ b/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', diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index db662f49c..0bc0e2364 100644 --- a/netbox/ipam/models/ip.py +++ b/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 diff --git a/netbox/ipam/models/l2vpn.py b/netbox/ipam/models/l2vpn.py new file mode 100644 index 000000000..dd8c51984 --- /dev/null +++ b/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.' + ) diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 7643a2617..f0e062721 100644 --- a/netbox/ipam/models/vlans.py +++ b/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() diff --git a/netbox/ipam/tables/__init__.py b/netbox/ipam/tables/__init__.py index 6f429e27d..3bde78af0 100644 --- a/netbox/ipam/tables/__init__.py +++ b/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 * diff --git a/netbox/ipam/tables/l2vpn.py b/netbox/ipam/tables/l2vpn.py new file mode 100644 index 000000000..a0e2f5d67 --- /dev/null +++ b/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 %} + {{ rt }}{% if not forloop.last %}
{% 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') diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index d99de6d20..a5ebef2c7 100644 --- a/netbox/ipam/tests/test_api.py +++ b/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 + } diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index d98fe889e..2b5fb0759 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/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) diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py index 09bc95799..3bd7e8ccb 100644 --- a/netbox/ipam/tests/test_models.py +++ b/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) diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 672cfbe08..dd3733d4d 100644 --- a/netbox/ipam/tests/test_views.py +++ b/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) diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 3c7ed2d1f..d27209fd2 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -186,4 +186,26 @@ urlpatterns = [ path('services//changelog/', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}), path('services//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//', views.L2VPNView.as_view(), name='l2vpn'), + path('l2vpns//edit/', views.L2VPNEditView.as_view(), name='l2vpn_edit'), + path('l2vpns//delete/', views.L2VPNDeleteView.as_view(), name='l2vpn_delete'), + path('l2vpns//changelog/', ObjectChangeLogView.as_view(), name='l2vpn_changelog', kwargs={'model': L2VPN}), + path('l2vpns//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//', views.L2VPNTerminationView.as_view(), name='l2vpntermination'), + path('l2vpn-terminations//edit/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_edit'), + path('l2vpn-terminations//delete/', views.L2VPNTerminationDeleteView.as_view(), name='l2vpntermination_delete'), + path('l2vpn-terminations//changelog/', ObjectChangeLogView.as_view(), name='l2vpntermination_changelog', kwargs={'model': L2VPNTermination}), + path('l2vpn-terminations//journal/', ObjectJournalView.as_view(), name='l2vpntermination_journal', kwargs={'model': L2VPNTermination}), ] diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 6682fc920..5545bc344 100644 --- a/netbox/ipam/views.py +++ b/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 diff --git a/netbox/netbox/api/viewsets/__init__.py b/netbox/netbox/api/viewsets/__init__.py index 462c07c6f..2d3780bde 100644 --- a/netbox/netbox/api/viewsets/__init__.py +++ b/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): """ diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index a13e8d192..62512943e 100644 --- a/netbox/netbox/authentication.py +++ b/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") diff --git a/netbox/netbox/config/parameters.py b/netbox/netbox/config/parameters.py index 68c96b38a..e2295888f 100644 --- a/netbox/netbox/config/parameters.py +++ b/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', diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 817da526b..6b2ee1f94 100644 --- a/netbox/netbox/models/features.py +++ b/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 diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index 9a55c263e..513cf4d9e 100644 --- a/netbox/netbox/navigation_menu.py +++ b/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=( diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 446198c61..b776650dc 100644 --- a/netbox/netbox/settings.py +++ b/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 diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 96efc0de7..82244bcd2 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/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: diff --git a/netbox/netbox/views/generic/mixins.py b/netbox/netbox/views/generic/mixins.py new file mode 100644 index 000000000..8e363f0a5 --- /dev/null +++ b/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 diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 88abfa48f..dc078a7e2 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/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, { diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index a4c41f871..a11139032 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -8,74 +8,78 @@ {% endblock %} {% block content %} -
-
-
-
- Circuit -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Provider{{ object.provider|linkify }}
Circuit ID{{ object.cid }}
Type{{ object.type|linkify }}
Status{% badge object.get_status_display bg_color=object.get_status_color %}
Tenant - {% if object.tenant.group %} - {{ object.tenant.group|linkify }} / - {% endif %} - {{ object.tenant|linkify|placeholder }} -
Install Date{{ object.install_date|annotated_date|placeholder }}
Termination Date{{ object.termination_date|annotated_date|placeholder }}
Commit Rate{{ object.commit_rate|humanize_speed|placeholder }}
Description{{ object.description|placeholder }}
-
+
+
+
+
Circuit
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Provider{{ object.provider|linkify }}
Circuit ID{{ object.cid }}
Type{{ object.type|linkify }}
Status{% badge object.get_status_display bg_color=object.get_status_color %}
Tenant + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / + {% endif %} + {{ object.tenant|linkify|placeholder }} +
Install Date{{ object.install_date|annotated_date|placeholder }}
Termination Date{{ object.termination_date|annotated_date|placeholder }}
Commit Rate{{ object.commit_rate|humanize_speed|placeholder }}
Description{{ object.description|placeholder }}
- {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% include 'inc/panels/comments.html' %} - {% plugin_left_page object %} -
-
- {% 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 %} -
-
-
-
- {% plugin_full_width_page object %} +
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %}
-
+
+ {% include 'inc/panels/comments.html' %} + {% include 'inc/panels/contacts.html' %} + {% include 'inc/panels/image_attachments.html' %} + {% plugin_right_page object %} +
+
+
+
+ {% 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' %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
{% endblock %} diff --git a/netbox/templates/circuits/circuittermination_edit.html b/netbox/templates/circuits/circuittermination_edit.html index f8393f945..606e12b5e 100644 --- a/netbox/templates/circuits/circuittermination_edit.html +++ b/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 %}
@@ -47,6 +48,13 @@ {% render_field form.pp_info %} {% render_field form.description %}
+ +
+
+
Custom Fields
+
+ {% render_custom_fields form %} +
{% endblock %} {# Override buttons block, 'Create & Add Another'/'_addanother' is not needed on a circuit. #} diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index b7d3aed56..f4e0ea6ca 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -2,7 +2,6 @@
- Termination - {{ side }} Side
{% if not termination and perms.circuits.add_circuittermination %} @@ -10,10 +9,10 @@ {% endif %} {% if termination and perms.circuits.change_circuittermination %} - + Edit - + Swap {% endif %} @@ -23,6 +22,7 @@ {% endif %}
+
Termination {{ side }}
{% if termination %} @@ -109,6 +109,33 @@ Description {{ termination.description|placeholder }} + + Tags + + {% for tag in termination.tags.all %} + {% tag tag %} + {% empty %} + {{ ''|placeholder }} + {% endfor %} + + + {% for group_name, fields in termination.get_custom_fields_by_group.items %} + + + {{ group_name|default:"Custom Fields" }} + + + {% for field, value in fields.items %} + + + {{ field }} + + + {% customfield_value field value %} + + + {% endfor %} + {% endfor %} {% else %} None diff --git a/netbox/templates/dcim/device/consoleports.html b/netbox/templates/dcim/device/consoleports.html index afc306bd4..04184be7c 100644 --- a/netbox/templates/dcim/device/consoleports.html +++ b/netbox/templates/dcim/device/consoleports.html @@ -17,7 +17,7 @@
- {% if perms.dcim.change_consoleport %} + {% if 'bulk_edit' in actions %} @@ -28,7 +28,7 @@ Disconnect {% endif %} - {% if perms.dcim.delete_consoleport %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/dcim/device/consoleserverports.html b/netbox/templates/dcim/device/consoleserverports.html index 5f244cdc7..ee1be91d7 100644 --- a/netbox/templates/dcim/device/consoleserverports.html +++ b/netbox/templates/dcim/device/consoleserverports.html @@ -17,7 +17,7 @@
- {% if perms.dcim.change_consoleserverport %} + {% if 'bulk_edit' in actions %} @@ -28,7 +28,7 @@ Disconnect {% endif %} - {% if perms.dcim.delete_consoleserverport %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/dcim/device/devicebays.html b/netbox/templates/dcim/device/devicebays.html index 5e33bdae0..7836935d9 100644 --- a/netbox/templates/dcim/device/devicebays.html +++ b/netbox/templates/dcim/device/devicebays.html @@ -17,7 +17,7 @@
- {% if perms.dcim.change_devicebay %} + {% if 'bulk_edit' in actions %} @@ -25,7 +25,7 @@ Edit {% endif %} - {% if perms.dcim.delete_devicebay %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/dcim/device/frontports.html b/netbox/templates/dcim/device/frontports.html index 0d0f9577c..8590fd50e 100644 --- a/netbox/templates/dcim/device/frontports.html +++ b/netbox/templates/dcim/device/frontports.html @@ -17,7 +17,7 @@
- {% if perms.dcim.change_frontport %} + {% if 'bulk_edit' in actions %} @@ -28,7 +28,7 @@ Disconnect {% endif %} - {% if perms.dcim.delete_frontport %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/dcim/device/interfaces.html b/netbox/templates/dcim/device/interfaces.html index 22f6d8be5..7db7ea0ae 100644 --- a/netbox/templates/dcim/device/interfaces.html +++ b/netbox/templates/dcim/device/interfaces.html @@ -53,7 +53,7 @@
- {% if perms.dcim.change_interface %} + {% if 'bulk_edit' in actions %} @@ -64,7 +64,7 @@ Disconnect {% endif %} - {% if perms.dcim.delete_interface %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/dcim/device/inventory.html b/netbox/templates/dcim/device/inventory.html index 18a0712f3..de981c545 100644 --- a/netbox/templates/dcim/device/inventory.html +++ b/netbox/templates/dcim/device/inventory.html @@ -17,7 +17,7 @@
- {% if perms.dcim.change_inventoryitem %} + {% if 'bulk_edit' in actions %} @@ -25,7 +25,7 @@ Edit {% endif %} - {% if perms.dcim.delete_inventoryitem %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/dcim/device/modulebays.html b/netbox/templates/dcim/device/modulebays.html index fc1c9a60d..3e4dadb30 100644 --- a/netbox/templates/dcim/device/modulebays.html +++ b/netbox/templates/dcim/device/modulebays.html @@ -17,7 +17,7 @@
- {% if perms.dcim.change_modulebay %} + {% if 'bulk_edit' in actions %} @@ -25,7 +25,7 @@ Edit {% endif %} - {% if perms.dcim.delete_modulebay %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/dcim/device/poweroutlets.html b/netbox/templates/dcim/device/poweroutlets.html index d312fbbd0..f9880a4b1 100644 --- a/netbox/templates/dcim/device/poweroutlets.html +++ b/netbox/templates/dcim/device/poweroutlets.html @@ -17,7 +17,7 @@
- {% if perms.dcim.change_powerport %} + {% if 'bulk_edit' in actions %} @@ -28,7 +28,7 @@ Disconnect {% endif %} - {% if perms.dcim.delete_poweroutlet %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/dcim/device/powerports.html b/netbox/templates/dcim/device/powerports.html index cf71e81ba..fc426a023 100644 --- a/netbox/templates/dcim/device/powerports.html +++ b/netbox/templates/dcim/device/powerports.html @@ -17,7 +17,7 @@
- {% if perms.dcim.change_powerport %} + {% if 'bulk_edit' in actions %} @@ -28,7 +28,7 @@ Disconnect {% endif %} - {% if perms.dcim.delete_powerport %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/dcim/device/rearports.html b/netbox/templates/dcim/device/rearports.html index 73341990f..eee67b6fd 100644 --- a/netbox/templates/dcim/device/rearports.html +++ b/netbox/templates/dcim/device/rearports.html @@ -17,7 +17,7 @@
- {% if perms.dcim.change_rearport %} + {% if 'bulk_edit' in actions %} @@ -28,7 +28,7 @@ Disconnect {% endif %} - {% if perms.dcim.delete_rearport %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index 7cbb224c9..38125e83c 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -86,6 +86,15 @@ {% render_field form.tenant %}
+
+
+
Virtual Chassis
+
+ {% render_field form.virtual_chassis %} + {% render_field form.vc_position %} + {% render_field form.vc_priority %} +
+ {% if form.custom_fields %}
diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 4c90bf5b8..3a7fe986a 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -104,6 +104,10 @@ LAG {{ object.lag|linkify|placeholder }} + + L2VPN + {{ object.l2vpn_termination.l2vpn|linkify|placeholder }} +
diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index ddda1ae31..d6fdfd0e1 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -72,6 +72,14 @@
{% endif %} +
+
+
Power over Ethernet (PoE)
+
+ {% render_field form.poe_mode %} + {% render_field form.poe_type %} +
+
802.1Q Switching
diff --git a/netbox/templates/inc/panels/custom_fields.html b/netbox/templates/inc/panels/custom_fields.html index b18d44030..90059447f 100644 --- a/netbox/templates/inc/panels/custom_fields.html +++ b/netbox/templates/inc/panels/custom_fields.html @@ -16,33 +16,7 @@ {{ field }} - {% 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 %} - {{ value|truncatechars:70 }} - {% elif field.type == 'json' and value %} -
{{ value|json }}
- {% 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 %}
{% endif %} - {% endfor %} - {% elif value %} - {{ value }} - {% elif field.required %} - Not defined - {% else %} - {{ ''|placeholder }} - {% endif %} + {% customfield_value field value %} {% endfor %} diff --git a/netbox/templates/ipam/aggregate/prefixes.html b/netbox/templates/ipam/aggregate/prefixes.html index d1b48429a..8256236f4 100644 --- a/netbox/templates/ipam/aggregate/prefixes.html +++ b/netbox/templates/ipam/aggregate/prefixes.html @@ -25,12 +25,12 @@
- {% if perms.ipam.change_prefix %} + {% if 'bulk_edit' in actions %} {% endif %} - {% if perms.ipam.delete_prefix %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/ipam/iprange/ip_addresses.html b/netbox/templates/ipam/iprange/ip_addresses.html index d9ac77fd0..61b2ee335 100644 --- a/netbox/templates/ipam/iprange/ip_addresses.html +++ b/netbox/templates/ipam/iprange/ip_addresses.html @@ -23,12 +23,12 @@
- {% if perms.ipam.change_ipaddress %} + {% if 'bulk_edit' in actions %} {% endif %} - {% if perms.ipam.delete_ipaddress %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/ipam/l2vpn.html b/netbox/templates/ipam/l2vpn.html new file mode 100644 index 000000000..130940b02 --- /dev/null +++ b/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 %} +
+
+
+
+ L2VPN Attributes +
+
+ Name + + + + + + + + + + + + + + + + + + + + + + +
{{ object.name|placeholder }}
Slug{{ object.slug|placeholder }}
Identifier{{ object.identifier|placeholder }}
Type{{ object.get_type_display }}
Description{{ object.description|placeholder }}
Tenant{{ object.tenant|placeholder }}
+
+
+ {% include 'inc/panels/contacts.html' %} + {% plugin_left_page object %} +
+
+ {% include 'inc/panels/tags.html' with tags=object.tags.all url='circuits:circuit_list' %} + {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %} +
+
+
+
+ {% include 'inc/panel_table.html' with table=import_targets_table heading="Import Route Targets" %} +
+
+ {% include 'inc/panel_table.html' with table=export_targets_table heading="Export Route Targets" %} +
+
+
+
+
+
Terminations
+
+ {% render_table terminations_table 'inc/table.html' %} +
+ {% if perms.ipam.add_l2vpntermination %} + + {% endif %} +
+
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/ipam/l2vpntermination.html b/netbox/templates/ipam/l2vpntermination.html new file mode 100644 index 000000000..b34d1a710 --- /dev/null +++ b/netbox/templates/ipam/l2vpntermination.html @@ -0,0 +1,31 @@ +{% extends 'generic/object.html' %} +{% load helpers %} + +{% block content %} +
+
+
+
+ L2VPN Attributes +
+
+ + + + + + + + + +
L2VPN{{ object.l2vpn|linkify }}
Assigned Object{{ object.assigned_object|linkify }}
+
+
+
+
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:l2vpntermination_list' %} +
+
+ +{% endblock %} diff --git a/netbox/templates/ipam/l2vpntermination_edit.html b/netbox/templates/ipam/l2vpntermination_edit.html new file mode 100644 index 000000000..4ba079eb5 --- /dev/null +++ b/netbox/templates/ipam/l2vpntermination_edit.html @@ -0,0 +1,49 @@ +{% extends 'generic/object_edit.html' %} +{% load helpers %} +{% load form_helpers %} + +{% block form %} +
+
+
L2VPN Termination
+
+ {% render_field form.l2vpn %} +
+
+ +
+
+
+
+
+ {% render_field form.device %} + {% render_field form.vlan %} +
+
+ {% render_field form.device %} + {% render_field form.interface %} +
+
+ {% render_field form.virtual_machine %} + {% render_field form.vminterface %} +
+
+
+
+{% endblock %} diff --git a/netbox/templates/ipam/prefix/ip_addresses.html b/netbox/templates/ipam/prefix/ip_addresses.html index d734b825f..31a22497d 100644 --- a/netbox/templates/ipam/prefix/ip_addresses.html +++ b/netbox/templates/ipam/prefix/ip_addresses.html @@ -23,12 +23,12 @@
- {% if perms.ipam.change_ipaddress %} + {% if 'bulk_edit' in actions %} {% endif %} - {% if perms.ipam.delete_ipaddress %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/ipam/prefix/ip_ranges.html b/netbox/templates/ipam/prefix/ip_ranges.html index 268c290a1..45b1d4fd0 100644 --- a/netbox/templates/ipam/prefix/ip_ranges.html +++ b/netbox/templates/ipam/prefix/ip_ranges.html @@ -23,12 +23,12 @@
- {% if perms.ipam.change_iprange %} + {% if 'bulk_edit' in actions %} {% endif %} - {% if perms.ipam.delete_iprange %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/ipam/prefix/prefixes.html b/netbox/templates/ipam/prefix/prefixes.html index 5d42596ba..46fa29581 100644 --- a/netbox/templates/ipam/prefix/prefixes.html +++ b/netbox/templates/ipam/prefix/prefixes.html @@ -25,12 +25,12 @@
- {% if perms.ipam.change_prefix %} + {% if 'bulk_edit' in actions %} {% endif %} - {% if perms.ipam.delete_prefix %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index fd0ba36a3..53bb75b8f 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -64,6 +64,10 @@ Description {{ object.description|placeholder }} + + L2VPN + {{ object.l2vpn_termination.l2vpn|linkify|placeholder }} +
diff --git a/netbox/templates/virtualization/cluster/virtual_machines.html b/netbox/templates/virtualization/cluster/virtual_machines.html index 953d9f940..9cb33258f 100644 --- a/netbox/templates/virtualization/cluster/virtual_machines.html +++ b/netbox/templates/virtualization/cluster/virtual_machines.html @@ -14,12 +14,12 @@
- {% if perms.virtualization.change_virtualmachine %} + {% if 'bulk_edit' in actions %} {% endif %} - {% if perms.virtualization.delete_virtualmachine %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/users/admin/forms.py b/netbox/users/admin/forms.py index bc3d44862..540735ecc 100644 --- a/netbox/users/admin/forms.py +++ b/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}' diff --git a/netbox/users/constants.py b/netbox/users/constants.py index e6917c482..1e6e7c71c 100644 --- a/netbox/users/constants.py +++ b/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' diff --git a/netbox/utilities/exceptions.py b/netbox/utilities/exceptions.py index 4ba62bc01..657e90745 100644 --- a/netbox/utilities/exceptions.py +++ b/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): diff --git a/netbox/utilities/management/commands/__init__.py b/netbox/utilities/management/commands/__init__.py index bdd4face6..2c261b0d3 100644 --- a/netbox/utilities/management/commands/__init__.py +++ b/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 diff --git a/netbox/utilities/permissions.py b/netbox/utilities/permissions.py index b11bf504a..b20aafce0 100644 --- a/netbox/utilities/permissions.py +++ b/netbox/utilities/permissions.py @@ -1,5 +1,14 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType +from django.db.models import Q + +__all__ = ( + 'get_permission_for_model', + 'permission_is_exempt', + 'qs_filter_from_constraints', + 'resolve_permission', + 'resolve_permission_ct', +) def get_permission_for_model(model, action): @@ -69,3 +78,29 @@ def permission_is_exempt(name): return True return False + + +def qs_filter_from_constraints(constraints, tokens=None): + """ + Construct a Q filter object from an iterable of ObjectPermission constraints. + + Args: + tokens: A dictionary mapping string tokens to be replaced with a value. + """ + if tokens is None: + tokens = {} + + def _replace_tokens(value, tokens): + if type(value) is list: + return list(map(lambda v: tokens.get(v, v), value)) + return tokens.get(value, value) + + params = Q() + for constraint in constraints: + if constraint: + params |= Q(**{k: _replace_tokens(v, tokens) for k, v in constraint.items()}) + else: + # Found null constraint; permit model-level access + return Q() + + return params diff --git a/netbox/utilities/querysets.py b/netbox/utilities/querysets.py index 97d2e8779..955a10d64 100644 --- a/netbox/utilities/querysets.py +++ b/netbox/utilities/querysets.py @@ -1,6 +1,7 @@ -from django.db.models import Q, QuerySet +from django.db.models import QuerySet -from utilities.permissions import permission_is_exempt +from users.constants import CONSTRAINT_TOKEN_USER +from utilities.permissions import permission_is_exempt, qs_filter_from_constraints class RestrictedQuerySet(QuerySet): @@ -28,23 +29,13 @@ class RestrictedQuerySet(QuerySet): # Filter the queryset to include only objects with allowed attributes else: - attrs = Q() - for perm_attrs in user._object_perm_cache[permission_required]: - if type(perm_attrs) is list: - for p in perm_attrs: - attrs |= Q(**p) - elif perm_attrs: - attrs |= Q(**perm_attrs) - else: - # Any permission with null constraints grants access to _all_ instances - attrs = Q() - break - else: - # for else, when no break - # avoid duplicates when JOIN on many-to-many fields without using DISTINCT. - # DISTINCT acts globally on the entire request, which may not be desirable. - allowed_objects = self.model.objects.filter(attrs) - attrs = Q(pk__in=allowed_objects) - qs = self.filter(attrs) + tokens = { + CONSTRAINT_TOKEN_USER: user, + } + attrs = qs_filter_from_constraints(user._object_perm_cache[permission_required], tokens) + # #8715: Avoid duplicates when JOIN on many-to-many fields without using DISTINCT. + # DISTINCT acts globally on the entire request, which may not be desirable. + allowed_objects = self.model.objects.filter(attrs) + qs = self.filter(pk__in=allowed_objects) return qs diff --git a/netbox/utilities/templates/builtins/customfield_value.html b/netbox/utilities/templates/builtins/customfield_value.html new file mode 100644 index 000000000..8fedb03d5 --- /dev/null +++ b/netbox/utilities/templates/builtins/customfield_value.html @@ -0,0 +1,27 @@ +{% 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 %} + {{ value|truncatechars:70 }} +{% elif field.type == 'json' and value %} +
{{ value|json }}
+{% elif field.type == 'multiselect' and value %} + {{ value|join:", " }} +{% elif field.type == 'object' and value %} + {{ value|linkify }} +{% elif field.type == 'multiobject' and value %} + {% for object in value %} + {{ object|linkify }}{% if not forloop.last %}
{% endif %} + {% endfor %} +{% elif value %} + {{ value }} +{% elif field.required %} + Not defined +{% else %} + {{ ''|placeholder }} +{% endif %} diff --git a/netbox/utilities/templates/navigation/menu.html b/netbox/utilities/templates/navigation/menu.html index dfc85968a..33a476081 100644 --- a/netbox/utilities/templates/navigation/menu.html +++ b/netbox/utilities/templates/navigation/menu.html @@ -1,58 +1,43 @@ {% load helpers %} +
+ + {% endfor %} diff --git a/netbox/utilities/templatetags/builtins/tags.py b/netbox/utilities/templatetags/builtins/tags.py index 666b6a31c..ed464b332 100644 --- a/netbox/utilities/templatetags/builtins/tags.py +++ b/netbox/utilities/templatetags/builtins/tags.py @@ -18,6 +18,21 @@ def tag(value, viewname=None): } +@register.inclusion_tag('builtins/customfield_value.html') +def customfield_value(customfield, value): + """ + Render a custom field value according to the field type. + + Args: + customfield: A CustomField instance + value: The custom field value applied to an object + """ + return { + 'customfield': customfield, + 'value': value, + } + + @register.inclusion_tag('builtins/badge.html') def badge(value, bg_color=None, show_empty=False): """ diff --git a/netbox/utilities/templatetags/navigation.py b/netbox/utilities/templatetags/navigation.py index ede8792fa..ef0657446 100644 --- a/netbox/utilities/templatetags/navigation.py +++ b/netbox/utilities/templatetags/navigation.py @@ -13,7 +13,26 @@ def nav(context: Context) -> Dict: """ Render the navigation menu. """ + user = context['request'].user + nav_items = [] + + # Construct the navigation menu based upon the current user's permissions + for menu in MENUS: + groups = [] + for group in menu.groups: + items = [] + for item in group.items: + if user.has_perms(item.permissions): + buttons = [ + button for button in item.buttons if user.has_perms(button.permissions) + ] + items.append((item, buttons)) + if items: + groups.append((group, items)) + if groups: + nav_items.append((menu, groups)) + return { - "nav_items": MENUS, + "nav_items": nav_items, "request": context["request"] } diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 731b67e43..51c411004 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -286,7 +286,7 @@ def prepare_cloned_fields(instance): """ # Generate the clone attributes from the instance if not hasattr(instance, 'clone'): - return None + return QueryDict() attrs = instance.clone() # Prepare querydict parameters diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index bd01b5533..c5816dca8 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -5,7 +5,9 @@ from dcim.api.nested_serializers import ( NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer, ) from dcim.choices import InterfaceModeChoices -from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer +from ipam.api.nested_serializers import ( + NestedIPAddressSerializer, NestedL2VPNTerminationSerializer, NestedVLANSerializer, NestedVRFSerializer, +) from ipam.models import VLAN from netbox.api import ChoiceField, SerializedPKRelatedField from netbox.api.serializers import NetBoxModelSerializer @@ -121,6 +123,7 @@ class VMInterfaceSerializer(NetBoxModelSerializer): many=True ) vrf = NestedVRFSerializer(required=False, allow_null=True) + l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True) count_ipaddresses = serializers.IntegerField(read_only=True) count_fhrp_groups = serializers.IntegerField(read_only=True) @@ -128,8 +131,8 @@ class VMInterfaceSerializer(NetBoxModelSerializer): model = VMInterface fields = [ 'id', 'url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'bridge', 'mtu', 'mac_address', - 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', 'custom_fields', 'created', - 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', + 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'vrf', 'l2vpn_termination', 'tags', 'custom_fields', + 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', ] def validate(self, data): diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 02560a962..98321976f 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -440,6 +440,12 @@ class VMInterface(NetBoxModel, BaseInterface): 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='vminterface', + ) class Meta: verbose_name = 'interface' @@ -498,3 +504,7 @@ class VMInterface(NetBoxModel, BaseInterface): @property def parent_object(self): return self.virtual_machine + + @property + def l2vpn_termination(self): + return self.l2vpn_terminations.first()