mirror of
https://github.com/netbox-community/netbox.git
synced 2026-02-27 11:07:39 +01:00
Compare commits
3 Commits
main
...
21356-etag
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
856701d8aa | ||
|
|
1a404f5c0f | ||
|
|
3320e07b70 |
@@ -31,6 +31,11 @@ The following data is available as context for Jinja2 templates:
|
||||
* `data` - A detailed representation of the object in its current state. This is typically equivalent to the model's representation in NetBox's REST API.
|
||||
* `snapshots` - Minimal "snapshots" of the object state both before and after the change was made; provided as a dictionary with keys named `prechange` and `postchange`. These are not as extensive as the fully serialized representation, but contain enough information to convey what has changed.
|
||||
|
||||
!!! warning "Deprecation of legacy fields"
|
||||
The "request_id" and "username" fields in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0.
|
||||
|
||||
Use `request.user.username` and `request.request_id` from the `request` object included in the callback context instead.
|
||||
|
||||
### Default Request Body
|
||||
|
||||
If no body template is specified, the request body will be populated with a JSON object containing the context data. For example, a newly created site might appear as follows:
|
||||
|
||||
@@ -88,3 +88,8 @@ The following context variables are available in to the text and link templates.
|
||||
| `request_id` | The unique request ID |
|
||||
| `data` | A complete serialized representation of the object |
|
||||
| `snapshots` | Pre- and post-change snapshots of the object |
|
||||
|
||||
!!! warning "Deprecation of legacy fields"
|
||||
The "request_id" and "username" fields in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0.
|
||||
|
||||
Use `request.user.username` and `request.request_id` from the `request` object included in the callback context instead.
|
||||
|
||||
@@ -43,6 +43,11 @@ The resulting webhook payload will look like the following:
|
||||
}
|
||||
```
|
||||
|
||||
!!! warning "Deprecation of legacy fields"
|
||||
The "request_id" and "username" fields in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0.
|
||||
|
||||
Use `request.user.username` and `request.request_id` from the `request` object included in the callback context instead.
|
||||
|
||||
!!! note "Consider namespacing webhook data"
|
||||
The data returned from all webhook callbacks will be compiled into a single `context` dictionary. Any existing keys within this dictionary will be overwritten by subsequent callbacks which include those keys. To avoid collisions with webhook data provided by other plugins, consider namespacing your plugin's data within a nested dictionary as such:
|
||||
|
||||
|
||||
@@ -921,7 +921,6 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
# 10 Gbps Ethernet
|
||||
TYPE_10GE_BR_D = '10gbase-br-d'
|
||||
TYPE_10GE_BR_U = '10gbase-br-u'
|
||||
TYPE_10GE_CU = '10gbase-cu'
|
||||
TYPE_10GE_CX4 = '10gbase-cx4'
|
||||
TYPE_10GE_ER = '10gbase-er'
|
||||
TYPE_10GE_LR = '10gbase-lr'
|
||||
@@ -944,7 +943,6 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
TYPE_40GE_FR4 = '40gbase-fr4'
|
||||
TYPE_40GE_LR4 = '40gbase-lr4'
|
||||
TYPE_40GE_SR4 = '40gbase-sr4'
|
||||
TYPE_40GE_SR4_BD = '40gbase-sr4-bd'
|
||||
|
||||
# 50 Gbps Ethernet
|
||||
TYPE_50GE_CR = '50gbase-cr'
|
||||
@@ -1194,7 +1192,6 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(
|
||||
(TYPE_10GE_BR_D, '10GBASE-BR-D (10GE BiDi Down)'),
|
||||
(TYPE_10GE_BR_U, '10GBASE-BR-U (10GE BiDi Up)'),
|
||||
(TYPE_10GE_CU, '10GBASE-CU (10GE DAC Passive Twinax)'),
|
||||
(TYPE_10GE_CX4, '10GBASE-CX4 (10GE DAC)'),
|
||||
(TYPE_10GE_ER, '10GBASE-ER (10GE)'),
|
||||
(TYPE_10GE_LR, '10GBASE-LR (10GE)'),
|
||||
@@ -1223,7 +1220,6 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(TYPE_40GE_FR4, '40GBASE-FR4 (40GE)'),
|
||||
(TYPE_40GE_LR4, '40GBASE-LR4 (40GE)'),
|
||||
(TYPE_40GE_SR4, '40GBASE-SR4 (40GE)'),
|
||||
(TYPE_40GE_SR4_BD, '40GBASE-SR4 (40GE BiDi)'),
|
||||
)
|
||||
),
|
||||
(
|
||||
|
||||
@@ -22,19 +22,7 @@ if TYPE_CHECKING:
|
||||
@strawberry.type
|
||||
class ConfigContextMixin:
|
||||
|
||||
@classmethod
|
||||
def get_queryset(cls, queryset, info: Info, **kwargs):
|
||||
queryset = super().get_queryset(queryset, info, **kwargs)
|
||||
|
||||
# If `config_context` is requested, call annotate_config_context_data() on the queryset
|
||||
selected = {f.name for f in info.selected_fields[0].selections}
|
||||
if 'config_context' in selected and hasattr(queryset, 'annotate_config_context_data'):
|
||||
return queryset.annotate_config_context_data()
|
||||
|
||||
return queryset
|
||||
|
||||
# Ensure `local_context_data` is fetched when `config_context` is requested
|
||||
@strawberry_django.field(only=['local_context_data'])
|
||||
@strawberry_django.field
|
||||
def config_context(self) -> strawberry.scalars.JSON:
|
||||
return self.get_config_context()
|
||||
|
||||
|
||||
@@ -34,6 +34,28 @@ HTTP_ACTIONS = {
|
||||
}
|
||||
|
||||
|
||||
class ETagMixin:
|
||||
"""
|
||||
Adds ETag header support to ViewSets. Generates ETags from `last_updated`
|
||||
(or `created` if unavailable).
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _get_etag(obj):
|
||||
"""Return a quoted ETag string for the given object, or None."""
|
||||
if ts := getattr(obj, 'last_updated', None) or getattr(obj, 'created', None):
|
||||
return f'"{ts.isoformat()}"'
|
||||
return None
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
serializer = self.get_serializer(instance)
|
||||
response = Response(serializer.data)
|
||||
if etag := self._get_etag(instance):
|
||||
response['ETag'] = etag
|
||||
return response
|
||||
|
||||
|
||||
class BaseViewSet(GenericViewSet):
|
||||
"""
|
||||
Base class for all API ViewSets. This is responsible for the enforcement of object-based permissions.
|
||||
@@ -95,6 +117,7 @@ class BaseViewSet(GenericViewSet):
|
||||
|
||||
|
||||
class NetBoxReadOnlyModelViewSet(
|
||||
ETagMixin,
|
||||
mixins.CustomFieldsMixin,
|
||||
mixins.ExportTemplatesMixin,
|
||||
drf_mixins.RetrieveModelMixin,
|
||||
@@ -105,6 +128,7 @@ class NetBoxReadOnlyModelViewSet(
|
||||
|
||||
|
||||
class NetBoxModelViewSet(
|
||||
ETagMixin,
|
||||
mixins.BulkUpdateModelMixin,
|
||||
mixins.BulkDestroyModelMixin,
|
||||
mixins.ObjectValidationMixin,
|
||||
@@ -191,7 +215,14 @@ class NetBoxModelViewSet(
|
||||
serializer = self.get_serializer(qs, many=bulk_create)
|
||||
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
response = Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
# Add ETag for single-object creation only (bulk returns a list, no single ETag)
|
||||
if not bulk_create:
|
||||
if etag := self._get_etag(qs):
|
||||
response['ETag'] = etag
|
||||
|
||||
return response
|
||||
|
||||
def perform_create(self, serializer):
|
||||
model = self.queryset.model
|
||||
@@ -211,6 +242,14 @@ class NetBoxModelViewSet(
|
||||
def update(self, request, *args, **kwargs):
|
||||
partial = kwargs.pop('partial', False)
|
||||
instance = self.get_object_with_snapshot()
|
||||
|
||||
# Enforce If-Match precondition (RFC 9110 §13.1.1)
|
||||
if (if_match := request.META.get('HTTP_IF_MATCH')) and if_match != '*':
|
||||
current_etag = self._get_etag(instance)
|
||||
provided = [e.strip() for e in if_match.split(',')]
|
||||
if current_etag and current_etag not in provided:
|
||||
return Response(status=status.HTTP_412_PRECONDITION_FAILED)
|
||||
|
||||
serializer = self.get_serializer(instance, data=request.data, partial=partial)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_update(serializer)
|
||||
@@ -221,8 +260,12 @@ class NetBoxModelViewSet(
|
||||
|
||||
# Re-serialize the instance(s) with prefetched data
|
||||
serializer = self.get_serializer(qs)
|
||||
response = Response(serializer.data)
|
||||
|
||||
return Response(serializer.data)
|
||||
if etag := self._get_etag(qs):
|
||||
response['ETag'] = etag
|
||||
|
||||
return response
|
||||
|
||||
def perform_update(self, serializer):
|
||||
model = self.queryset.model
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-02-26 05:25+0000\n"
|
||||
"POT-Creation-Date: 2026-02-21 05:16+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -41,9 +41,9 @@ msgstr ""
|
||||
|
||||
#: netbox/circuits/choices.py:21 netbox/dcim/choices.py:20
|
||||
#: netbox/dcim/choices.py:102 netbox/dcim/choices.py:204
|
||||
#: netbox/dcim/choices.py:257 netbox/dcim/choices.py:1933
|
||||
#: netbox/dcim/choices.py:1991 netbox/dcim/choices.py:2058
|
||||
#: netbox/dcim/choices.py:2080 netbox/virtualization/choices.py:20
|
||||
#: netbox/dcim/choices.py:257 netbox/dcim/choices.py:1929
|
||||
#: netbox/dcim/choices.py:1987 netbox/dcim/choices.py:2054
|
||||
#: netbox/dcim/choices.py:2076 netbox/virtualization/choices.py:20
|
||||
#: netbox/virtualization/choices.py:46 netbox/vpn/choices.py:18
|
||||
#: netbox/vpn/choices.py:281
|
||||
msgid "Planned"
|
||||
@@ -57,8 +57,8 @@ msgstr ""
|
||||
#: netbox/core/tables/tasks.py:23 netbox/dcim/choices.py:22
|
||||
#: netbox/dcim/choices.py:103 netbox/dcim/choices.py:155
|
||||
#: netbox/dcim/choices.py:203 netbox/dcim/choices.py:256
|
||||
#: netbox/dcim/choices.py:1990 netbox/dcim/choices.py:2057
|
||||
#: netbox/dcim/choices.py:2079 netbox/extras/tables/tables.py:642
|
||||
#: netbox/dcim/choices.py:1986 netbox/dcim/choices.py:2053
|
||||
#: netbox/dcim/choices.py:2075 netbox/extras/tables/tables.py:642
|
||||
#: netbox/ipam/choices.py:31 netbox/ipam/choices.py:49
|
||||
#: netbox/ipam/choices.py:69 netbox/ipam/choices.py:154
|
||||
#: netbox/templates/extras/configcontext.html:29
|
||||
@@ -70,8 +70,8 @@ msgid "Active"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/circuits/choices.py:24 netbox/dcim/choices.py:202
|
||||
#: netbox/dcim/choices.py:255 netbox/dcim/choices.py:1989
|
||||
#: netbox/dcim/choices.py:2059 netbox/dcim/choices.py:2078
|
||||
#: netbox/dcim/choices.py:255 netbox/dcim/choices.py:1985
|
||||
#: netbox/dcim/choices.py:2055 netbox/dcim/choices.py:2074
|
||||
#: netbox/virtualization/choices.py:24 netbox/virtualization/choices.py:44
|
||||
msgid "Offline"
|
||||
msgstr ""
|
||||
@@ -84,7 +84,7 @@ msgstr ""
|
||||
msgid "Decommissioned"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/circuits/choices.py:90 netbox/dcim/choices.py:2002
|
||||
#: netbox/circuits/choices.py:90 netbox/dcim/choices.py:1998
|
||||
#: netbox/dcim/tables/devices.py:1208 netbox/templates/dcim/interface.html:148
|
||||
#: netbox/tenancy/choices.py:17
|
||||
msgid "Primary"
|
||||
@@ -1995,7 +1995,7 @@ msgstr ""
|
||||
#: netbox/core/choices.py:22 netbox/core/choices.py:59
|
||||
#: netbox/core/constants.py:21 netbox/core/tables/tasks.py:35
|
||||
#: netbox/dcim/choices.py:206 netbox/dcim/choices.py:259
|
||||
#: netbox/dcim/choices.py:1992 netbox/dcim/choices.py:2082
|
||||
#: netbox/dcim/choices.py:1988 netbox/dcim/choices.py:2078
|
||||
#: netbox/virtualization/choices.py:48
|
||||
msgid "Failed"
|
||||
msgstr ""
|
||||
@@ -2181,7 +2181,7 @@ msgid "User name"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/core/forms/bulk_edit.py:25 netbox/core/forms/filtersets.py:47
|
||||
#: netbox/core/tables/data.py:28 netbox/dcim/choices.py:2040
|
||||
#: netbox/core/tables/data.py:28 netbox/dcim/choices.py:2036
|
||||
#: netbox/dcim/forms/bulk_edit.py:1105 netbox/dcim/forms/bulk_edit.py:1386
|
||||
#: netbox/dcim/forms/filtersets.py:1619 netbox/dcim/forms/filtersets.py:1712
|
||||
#: netbox/dcim/tables/devices.py:581 netbox/dcim/tables/devicetypes.py:233
|
||||
@@ -2375,7 +2375,7 @@ msgstr ""
|
||||
msgid "Rack Elevations"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/core/forms/model_forms.py:160 netbox/dcim/choices.py:1911
|
||||
#: netbox/core/forms/model_forms.py:160 netbox/dcim/choices.py:1907
|
||||
#: netbox/dcim/forms/bulk_edit.py:944 netbox/dcim/forms/bulk_edit.py:1340
|
||||
#: netbox/dcim/forms/bulk_edit.py:1361 netbox/dcim/tables/racks.py:144
|
||||
#: netbox/netbox/navigation/menu.py:316 netbox/netbox/navigation/menu.py:320
|
||||
@@ -3041,8 +3041,8 @@ msgid "Staging"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:23 netbox/dcim/choices.py:208
|
||||
#: netbox/dcim/choices.py:260 netbox/dcim/choices.py:1934
|
||||
#: netbox/dcim/choices.py:2083 netbox/virtualization/choices.py:23
|
||||
#: netbox/dcim/choices.py:260 netbox/dcim/choices.py:1930
|
||||
#: netbox/dcim/choices.py:2079 netbox/virtualization/choices.py:23
|
||||
#: netbox/virtualization/choices.py:49 netbox/vpn/choices.py:282
|
||||
msgid "Decommissioning"
|
||||
msgstr ""
|
||||
@@ -3108,7 +3108,7 @@ msgstr ""
|
||||
msgid "Millimeters"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:115 netbox/dcim/choices.py:1956
|
||||
#: netbox/dcim/choices.py:115 netbox/dcim/choices.py:1952
|
||||
msgid "Inches"
|
||||
msgstr ""
|
||||
|
||||
@@ -3186,7 +3186,7 @@ msgid "Rear"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:205 netbox/dcim/choices.py:258
|
||||
#: netbox/dcim/choices.py:2081 netbox/virtualization/choices.py:47
|
||||
#: netbox/dcim/choices.py:2077 netbox/virtualization/choices.py:47
|
||||
msgid "Staged"
|
||||
msgstr ""
|
||||
|
||||
@@ -3219,7 +3219,7 @@ msgid "Top to bottom"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:235 netbox/dcim/choices.py:280
|
||||
#: netbox/dcim/choices.py:1566
|
||||
#: netbox/dcim/choices.py:1562
|
||||
msgid "Passive"
|
||||
msgstr ""
|
||||
|
||||
@@ -3248,8 +3248,8 @@ msgid "Proprietary"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:606 netbox/dcim/choices.py:853
|
||||
#: netbox/dcim/choices.py:1478 netbox/dcim/choices.py:1480
|
||||
#: netbox/dcim/choices.py:1716 netbox/dcim/choices.py:1718
|
||||
#: netbox/dcim/choices.py:1474 netbox/dcim/choices.py:1476
|
||||
#: netbox/dcim/choices.py:1712 netbox/dcim/choices.py:1714
|
||||
#: netbox/netbox/navigation/menu.py:212
|
||||
msgid "Other"
|
||||
msgstr ""
|
||||
@@ -3262,11 +3262,11 @@ msgstr ""
|
||||
msgid "Physical"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:884 netbox/dcim/choices.py:1153
|
||||
#: netbox/dcim/choices.py:884 netbox/dcim/choices.py:1151
|
||||
msgid "Virtual"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:885 netbox/dcim/choices.py:1355
|
||||
#: netbox/dcim/choices.py:885 netbox/dcim/choices.py:1351
|
||||
#: netbox/dcim/forms/bulk_edit.py:1546 netbox/dcim/forms/filtersets.py:1577
|
||||
#: netbox/dcim/forms/filtersets.py:1703 netbox/dcim/forms/model_forms.py:1125
|
||||
#: netbox/dcim/forms/model_forms.py:1589 netbox/netbox/navigation/menu.py:150
|
||||
@@ -3275,11 +3275,11 @@ msgstr ""
|
||||
msgid "Wireless"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1151
|
||||
#: netbox/dcim/choices.py:1149
|
||||
msgid "Virtual interfaces"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1154 netbox/dcim/forms/bulk_edit.py:1399
|
||||
#: netbox/dcim/choices.py:1152 netbox/dcim/forms/bulk_edit.py:1399
|
||||
#: netbox/dcim/forms/bulk_import.py:949 netbox/dcim/forms/model_forms.py:1107
|
||||
#: netbox/dcim/tables/devices.py:741 netbox/templates/dcim/interface.html:112
|
||||
#: netbox/virtualization/forms/bulk_edit.py:177
|
||||
@@ -3288,67 +3288,67 @@ msgstr ""
|
||||
msgid "Bridge"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1155
|
||||
#: netbox/dcim/choices.py:1153
|
||||
msgid "Link Aggregation Group (LAG)"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1159
|
||||
#: netbox/dcim/choices.py:1157
|
||||
msgid "FastEthernet (100 Mbps)"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1168
|
||||
#: netbox/dcim/choices.py:1166
|
||||
msgid "GigabitEthernet (1 Gbps)"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1186
|
||||
#: netbox/dcim/choices.py:1184
|
||||
msgid "2.5/5 Gbps Ethernet"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1193
|
||||
#: netbox/dcim/choices.py:1191
|
||||
msgid "10 Gbps Ethernet"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1209
|
||||
#: netbox/dcim/choices.py:1206
|
||||
msgid "25 Gbps Ethernet"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1219
|
||||
#: netbox/dcim/choices.py:1216
|
||||
msgid "40 Gbps Ethernet"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1230
|
||||
#: netbox/dcim/choices.py:1226
|
||||
msgid "50 Gbps Ethernet"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1240
|
||||
#: netbox/dcim/choices.py:1236
|
||||
msgid "100 Gbps Ethernet"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1261
|
||||
#: netbox/dcim/choices.py:1257
|
||||
msgid "200 Gbps Ethernet"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1275
|
||||
#: netbox/dcim/choices.py:1271
|
||||
msgid "400 Gbps Ethernet"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1293
|
||||
#: netbox/dcim/choices.py:1289
|
||||
msgid "800 Gbps Ethernet"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1302
|
||||
#: netbox/dcim/choices.py:1298
|
||||
msgid "Pluggable transceivers"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1339
|
||||
#: netbox/dcim/choices.py:1335
|
||||
msgid "Backplane Ethernet"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1371
|
||||
#: netbox/dcim/choices.py:1367
|
||||
msgid "Cellular"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1423 netbox/dcim/forms/filtersets.py:425
|
||||
#: netbox/dcim/choices.py:1419 netbox/dcim/forms/filtersets.py:425
|
||||
#: netbox/dcim/forms/filtersets.py:911 netbox/dcim/forms/filtersets.py:1112
|
||||
#: netbox/dcim/forms/filtersets.py:1910
|
||||
#: netbox/templates/dcim/inventoryitem.html:56
|
||||
@@ -3356,255 +3356,255 @@ msgstr ""
|
||||
msgid "Serial"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1438
|
||||
#: netbox/dcim/choices.py:1434
|
||||
msgid "Coaxial"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1459
|
||||
#: netbox/dcim/choices.py:1455
|
||||
msgid "Stacking"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1511
|
||||
#: netbox/dcim/choices.py:1507
|
||||
msgid "Half"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1512
|
||||
#: netbox/dcim/choices.py:1508
|
||||
msgid "Full"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1513 netbox/netbox/preferences.py:32
|
||||
#: netbox/dcim/choices.py:1509 netbox/netbox/preferences.py:32
|
||||
#: netbox/wireless/choices.py:480
|
||||
msgid "Auto"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1525
|
||||
#: netbox/dcim/choices.py:1521
|
||||
msgid "Access"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1526 netbox/ipam/tables/vlans.py:150
|
||||
#: netbox/dcim/choices.py:1522 netbox/ipam/tables/vlans.py:150
|
||||
#: netbox/ipam/tables/vlans.py:210
|
||||
#: netbox/templates/dcim/inc/interface_vlans_table.html:7
|
||||
msgid "Tagged"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1527
|
||||
#: netbox/dcim/choices.py:1523
|
||||
msgid "Tagged (All)"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1528 netbox/templates/ipam/vlan_edit.html:26
|
||||
#: netbox/dcim/choices.py:1524 netbox/templates/ipam/vlan_edit.html:26
|
||||
msgid "Q-in-Q (802.1ad)"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1557
|
||||
#: netbox/dcim/choices.py:1553
|
||||
msgid "IEEE Standard"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1568
|
||||
#: netbox/dcim/choices.py:1564
|
||||
msgid "Passive 24V (2-pair)"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1569
|
||||
#: netbox/dcim/choices.py:1565
|
||||
msgid "Passive 24V (4-pair)"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1570
|
||||
#: netbox/dcim/choices.py:1566
|
||||
msgid "Passive 48V (2-pair)"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1571
|
||||
#: netbox/dcim/choices.py:1567
|
||||
msgid "Passive 48V (4-pair)"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1644
|
||||
#: netbox/dcim/choices.py:1640
|
||||
msgid "Copper"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1667
|
||||
#: netbox/dcim/choices.py:1663
|
||||
msgid "Fiber Optic"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1703 netbox/dcim/choices.py:1917
|
||||
#: netbox/dcim/choices.py:1699 netbox/dcim/choices.py:1913
|
||||
msgid "USB"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1759
|
||||
#: netbox/dcim/choices.py:1755
|
||||
msgid "Single"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1761
|
||||
#: netbox/dcim/choices.py:1757
|
||||
msgid "1C1P"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1762
|
||||
#: netbox/dcim/choices.py:1758
|
||||
msgid "1C2P"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1763
|
||||
#: netbox/dcim/choices.py:1759
|
||||
msgid "1C4P"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1764
|
||||
#: netbox/dcim/choices.py:1760
|
||||
msgid "1C6P"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1765
|
||||
#: netbox/dcim/choices.py:1761
|
||||
msgid "1C8P"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1766
|
||||
#: netbox/dcim/choices.py:1762
|
||||
msgid "1C12P"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1767
|
||||
#: netbox/dcim/choices.py:1763
|
||||
msgid "1C16P"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1771
|
||||
#: netbox/dcim/choices.py:1767
|
||||
msgid "Trunk"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1773
|
||||
#: netbox/dcim/choices.py:1769
|
||||
msgid "2C1P trunk"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1774
|
||||
#: netbox/dcim/choices.py:1770
|
||||
msgid "2C2P trunk"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1775
|
||||
#: netbox/dcim/choices.py:1771
|
||||
msgid "2C4P trunk"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1776
|
||||
#: netbox/dcim/choices.py:1772
|
||||
msgid "2C4P trunk (shuffle)"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1777
|
||||
#: netbox/dcim/choices.py:1773
|
||||
msgid "2C6P trunk"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1778
|
||||
#: netbox/dcim/choices.py:1774
|
||||
msgid "2C8P trunk"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1779
|
||||
#: netbox/dcim/choices.py:1775
|
||||
msgid "2C12P trunk"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1780
|
||||
#: netbox/dcim/choices.py:1776
|
||||
msgid "4C1P trunk"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1781
|
||||
#: netbox/dcim/choices.py:1777
|
||||
msgid "4C2P trunk"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1782
|
||||
#: netbox/dcim/choices.py:1778
|
||||
msgid "4C4P trunk"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1783
|
||||
#: netbox/dcim/choices.py:1779
|
||||
msgid "4C4P trunk (shuffle)"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1784
|
||||
#: netbox/dcim/choices.py:1780
|
||||
msgid "4C6P trunk"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1785
|
||||
#: netbox/dcim/choices.py:1781
|
||||
msgid "4C8P trunk"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1786
|
||||
#: netbox/dcim/choices.py:1782
|
||||
msgid "8C4P trunk"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1790
|
||||
#: netbox/dcim/choices.py:1786
|
||||
msgid "Breakout"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1792
|
||||
#: netbox/dcim/choices.py:1788
|
||||
msgid "1C4P:4C1P breakout"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1793
|
||||
#: netbox/dcim/choices.py:1789
|
||||
msgid "1C6P:6C1P breakout"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1794
|
||||
#: netbox/dcim/choices.py:1790
|
||||
msgid "2C4P:8C1P breakout (shuffle)"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1852
|
||||
#: netbox/dcim/choices.py:1848
|
||||
msgid "Copper - Twisted Pair (UTP/STP)"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1866
|
||||
#: netbox/dcim/choices.py:1862
|
||||
msgid "Copper - Twinax (DAC)"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1873
|
||||
#: netbox/dcim/choices.py:1869
|
||||
msgid "Copper - Coaxial"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1888
|
||||
#: netbox/dcim/choices.py:1884
|
||||
msgid "Fiber - Multimode"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1899
|
||||
#: netbox/dcim/choices.py:1895
|
||||
msgid "Fiber - Single-mode"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1907
|
||||
#: netbox/dcim/choices.py:1903
|
||||
msgid "Fiber - Other"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1932 netbox/dcim/forms/filtersets.py:1402
|
||||
#: netbox/dcim/choices.py:1928 netbox/dcim/forms/filtersets.py:1402
|
||||
msgid "Connected"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1951 netbox/netbox/choices.py:177
|
||||
#: netbox/dcim/choices.py:1947 netbox/netbox/choices.py:177
|
||||
msgid "Kilometers"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1952 netbox/netbox/choices.py:178
|
||||
#: netbox/dcim/choices.py:1948 netbox/netbox/choices.py:178
|
||||
#: netbox/templates/dcim/cable_trace.html:65
|
||||
msgid "Meters"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1953
|
||||
#: netbox/dcim/choices.py:1949
|
||||
msgid "Centimeters"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1954 netbox/netbox/choices.py:179
|
||||
#: netbox/dcim/choices.py:1950 netbox/netbox/choices.py:179
|
||||
msgid "Miles"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:1955 netbox/netbox/choices.py:180
|
||||
#: netbox/dcim/choices.py:1951 netbox/netbox/choices.py:180
|
||||
#: netbox/templates/dcim/cable_trace.html:66
|
||||
msgid "Feet"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:2003
|
||||
#: netbox/dcim/choices.py:1999
|
||||
msgid "Redundant"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:2024
|
||||
#: netbox/dcim/choices.py:2020
|
||||
msgid "Single phase"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:2025
|
||||
#: netbox/dcim/choices.py:2021
|
||||
msgid "Three-phase"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:2041 netbox/extras/choices.py:53
|
||||
#: netbox/dcim/choices.py:2037 netbox/extras/choices.py:53
|
||||
#: netbox/netbox/preferences.py:45 netbox/netbox/preferences.py:70
|
||||
#: netbox/templates/extras/customfield.html:78 netbox/vpn/choices.py:20
|
||||
#: netbox/wireless/choices.py:27
|
||||
msgid "Disabled"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/dcim/choices.py:2042
|
||||
#: netbox/dcim/choices.py:2038
|
||||
msgid "Faulty"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -114,7 +114,12 @@ class APIViewTestCases:
|
||||
|
||||
# Try GET to permitted object
|
||||
url = self._get_detail_url(instance1)
|
||||
self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_200_OK)
|
||||
response = self.client.get(url, **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
# Verify ETag header is present for objects with timestamps
|
||||
if issubclass(self.model, ChangeLoggingMixin):
|
||||
self.assertIn('ETag', response, "ETag header missing from detail response")
|
||||
|
||||
# Try GET to non-permitted object
|
||||
url = self._get_detail_url(instance2)
|
||||
@@ -367,6 +372,46 @@ class APIViewTestCases:
|
||||
self.assertEqual(objectchange.action, ObjectChangeActionChoices.ACTION_UPDATE)
|
||||
self.assertEqual(objectchange.message, data['changelog_message'])
|
||||
|
||||
def test_update_object_with_etag(self):
|
||||
"""
|
||||
PATCH an object using a valid If-Match ETag → expect 200.
|
||||
PATCH again with the now-stale ETag → expect 412.
|
||||
"""
|
||||
if not issubclass(self.model, ChangeLoggingMixin):
|
||||
self.skipTest("Model does not support ETags")
|
||||
|
||||
self.add_permissions(
|
||||
f'{self.model._meta.app_label}.view_{self.model._meta.model_name}',
|
||||
f'{self.model._meta.app_label}.change_{self.model._meta.model_name}',
|
||||
)
|
||||
instance = self._get_queryset().first()
|
||||
url = self._get_detail_url(instance)
|
||||
update_data = self.update_data or getattr(self, 'create_data')[0]
|
||||
|
||||
# Fetch current ETag
|
||||
get_response = self.client.get(url, **self.header)
|
||||
self.assertHttpStatus(get_response, status.HTTP_200_OK)
|
||||
etag = get_response.get('ETag')
|
||||
self.assertIsNotNone(etag, "No ETag returned by GET")
|
||||
|
||||
# PATCH with correct ETag → 200
|
||||
response = self.client.patch(
|
||||
url, update_data, format='json',
|
||||
**{**self.header, 'HTTP_IF_MATCH': etag}
|
||||
)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
new_etag = response.get('ETag')
|
||||
self.assertIsNotNone(new_etag)
|
||||
self.assertNotEqual(etag, new_etag) # ETag must change after update
|
||||
|
||||
# PATCH with the old (stale) ETag → 412
|
||||
with disable_warnings('django.request'):
|
||||
response = self.client.patch(
|
||||
url, update_data, format='json',
|
||||
**{**self.header, 'HTTP_IF_MATCH': etag}
|
||||
)
|
||||
self.assertHttpStatus(response, status.HTTP_412_PRECONDITION_FAILED)
|
||||
|
||||
def test_bulk_update_objects(self):
|
||||
"""
|
||||
PATCH a set of objects in a single request.
|
||||
|
||||
Reference in New Issue
Block a user