ipfabric_netbox 4.3.2b9__py3-none-any.whl → 4.3.2b11__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of ipfabric_netbox might be problematic. Click here for more details.
- ipfabric_netbox/__init__.py +1 -1
- ipfabric_netbox/api/serializers.py +112 -7
- ipfabric_netbox/api/urls.py +6 -0
- ipfabric_netbox/api/views.py +23 -0
- ipfabric_netbox/choices.py +74 -40
- ipfabric_netbox/data/endpoint.json +52 -0
- ipfabric_netbox/data/filters.json +51 -0
- ipfabric_netbox/data/transform_map.json +190 -176
- ipfabric_netbox/exceptions.py +7 -5
- ipfabric_netbox/filtersets.py +310 -41
- ipfabric_netbox/forms.py +330 -80
- ipfabric_netbox/graphql/__init__.py +6 -0
- ipfabric_netbox/graphql/enums.py +5 -5
- ipfabric_netbox/graphql/filters.py +56 -4
- ipfabric_netbox/graphql/schema.py +28 -0
- ipfabric_netbox/graphql/types.py +61 -1
- ipfabric_netbox/jobs.py +12 -1
- ipfabric_netbox/migrations/0022_prepare_for_filters.py +182 -0
- ipfabric_netbox/migrations/0023_populate_filters_data.py +303 -0
- ipfabric_netbox/migrations/0024_finish_filters.py +29 -0
- ipfabric_netbox/migrations/0025_add_vss_chassis_endpoint.py +166 -0
- ipfabric_netbox/models.py +432 -17
- ipfabric_netbox/navigation.py +98 -24
- ipfabric_netbox/tables.py +194 -9
- ipfabric_netbox/templates/ipfabric_netbox/htmx_list.html +5 -0
- ipfabric_netbox/templates/ipfabric_netbox/inc/combined_expressions.html +59 -0
- ipfabric_netbox/templates/ipfabric_netbox/inc/combined_expressions_content.html +39 -0
- ipfabric_netbox/templates/ipfabric_netbox/inc/endpoint_filters_with_selector.html +54 -0
- ipfabric_netbox/templates/ipfabric_netbox/ipfabricendpoint.html +39 -0
- ipfabric_netbox/templates/ipfabric_netbox/ipfabricfilter.html +51 -0
- ipfabric_netbox/templates/ipfabric_netbox/ipfabricfilterexpression.html +39 -0
- ipfabric_netbox/templates/ipfabric_netbox/ipfabricfilterexpression_edit.html +150 -0
- ipfabric_netbox/templates/ipfabric_netbox/ipfabricsync.html +1 -1
- ipfabric_netbox/templates/ipfabric_netbox/ipfabrictransformmap.html +16 -2
- ipfabric_netbox/templatetags/ipfabric_netbox_helpers.py +68 -0
- ipfabric_netbox/tests/api/test_api.py +333 -13
- ipfabric_netbox/tests/test_filtersets.py +2592 -0
- ipfabric_netbox/tests/test_forms.py +1349 -74
- ipfabric_netbox/tests/test_models.py +242 -34
- ipfabric_netbox/tests/test_views.py +2031 -26
- ipfabric_netbox/urls.py +35 -0
- ipfabric_netbox/utilities/endpoint.py +83 -0
- ipfabric_netbox/utilities/filters.py +88 -0
- ipfabric_netbox/utilities/ipfutils.py +393 -377
- ipfabric_netbox/utilities/logging.py +7 -7
- ipfabric_netbox/utilities/transform_map.py +144 -5
- ipfabric_netbox/views.py +719 -5
- {ipfabric_netbox-4.3.2b9.dist-info → ipfabric_netbox-4.3.2b11.dist-info}/METADATA +2 -2
- {ipfabric_netbox-4.3.2b9.dist-info → ipfabric_netbox-4.3.2b11.dist-info}/RECORD +50 -33
- {ipfabric_netbox-4.3.2b9.dist-info → ipfabric_netbox-4.3.2b11.dist-info}/WHEEL +1 -1
ipfabric_netbox/views.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
1
3
|
from core.choices import ObjectChangeActionChoices
|
|
2
4
|
from dcim.models import Device
|
|
3
5
|
from dcim.models import Site
|
|
@@ -37,7 +39,11 @@ from utilities.views import GetRelatedModelsMixin
|
|
|
37
39
|
from utilities.views import register_model_view
|
|
38
40
|
from utilities.views import ViewTab
|
|
39
41
|
|
|
42
|
+
from .choices import IPFabricSourceTypeChoices
|
|
40
43
|
from .filtersets import IPFabricDataFilterSet
|
|
44
|
+
from .filtersets import IPFabricEndpointFilterSet
|
|
45
|
+
from .filtersets import IPFabricFilterExpressionFilterSet
|
|
46
|
+
from .filtersets import IPFabricFilterFilterSet
|
|
41
47
|
from .filtersets import IPFabricIngestionChangeFilterSet
|
|
42
48
|
from .filtersets import IPFabricIngestionFilterSet
|
|
43
49
|
from .filtersets import IPFabricIngestionIssueFilterSet
|
|
@@ -46,6 +52,12 @@ from .filtersets import IPFabricSourceFilterSet
|
|
|
46
52
|
from .filtersets import IPFabricSyncFilterSet
|
|
47
53
|
from .filtersets import IPFabricTransformMapFilterSet
|
|
48
54
|
from .filtersets import IPFabricTransformMapGroupFilterSet
|
|
55
|
+
from .forms import IPFabricFilterBulkEditForm
|
|
56
|
+
from .forms import IPFabricFilterBulkImportForm
|
|
57
|
+
from .forms import IPFabricFilterExpressionBulkEditForm
|
|
58
|
+
from .forms import IPFabricFilterExpressionBulkImportForm
|
|
59
|
+
from .forms import IPFabricFilterExpressionForm
|
|
60
|
+
from .forms import IPFabricFilterForm
|
|
49
61
|
from .forms import IPFabricIngestionFilterForm
|
|
50
62
|
from .forms import IPFabricIngestionMergeForm
|
|
51
63
|
from .forms import IPFabricRelationshipFieldForm
|
|
@@ -65,6 +77,9 @@ from .forms import IPFabricTransformMapGroupBulkEditForm
|
|
|
65
77
|
from .forms import IPFabricTransformMapGroupBulkImportForm
|
|
66
78
|
from .forms import IPFabricTransformMapGroupForm
|
|
67
79
|
from .models import IPFabricData
|
|
80
|
+
from .models import IPFabricEndpoint
|
|
81
|
+
from .models import IPFabricFilter
|
|
82
|
+
from .models import IPFabricFilterExpression
|
|
68
83
|
from .models import IPFabricIngestion
|
|
69
84
|
from .models import IPFabricIngestionIssue
|
|
70
85
|
from .models import IPFabricRelationshipField
|
|
@@ -76,6 +91,9 @@ from .models import IPFabricTransformMap
|
|
|
76
91
|
from .models import IPFabricTransformMapGroup
|
|
77
92
|
from .tables import DeviceIPFTable
|
|
78
93
|
from .tables import IPFabricDataTable
|
|
94
|
+
from .tables import IPFabricEndpointTable
|
|
95
|
+
from .tables import IPFabricFilterExpressionTable
|
|
96
|
+
from .tables import IPFabricFilterTable
|
|
79
97
|
from .tables import IPFabricIngestionChangesTable
|
|
80
98
|
from .tables import IPFabricIngestionIssuesTable
|
|
81
99
|
from .tables import IPFabricIngestionTable
|
|
@@ -86,11 +104,221 @@ from .tables import IPFabricSyncTable
|
|
|
86
104
|
from .tables import IPFabricTransformFieldTable
|
|
87
105
|
from .tables import IPFabricTransformMapGroupTable
|
|
88
106
|
from .tables import IPFabricTransformMapTable
|
|
107
|
+
from .utilities.filters import get_filter_expression_test_candidates
|
|
89
108
|
from .utilities.ipfutils import IPFabric
|
|
90
109
|
from .utilities.transform_map import build_transform_maps
|
|
91
110
|
from .utilities.transform_map import get_transform_map
|
|
92
111
|
|
|
93
112
|
|
|
113
|
+
# region - Base Classes for Combined Expressions Views
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class CombinedExpressionsBaseView(LoginRequiredMixin, View):
|
|
117
|
+
"""Base view for displaying combined filter expressions in an HTMX modal.
|
|
118
|
+
|
|
119
|
+
This base class provides common functionality for views that display
|
|
120
|
+
combined/merged filter expressions. Subclasses must implement
|
|
121
|
+
get_context_data() to provide view-specific context.
|
|
122
|
+
|
|
123
|
+
The template (inc/combined_expressions.html) supports three context types:
|
|
124
|
+
- "filter": Display merged expressions for a single IPFabricFilter
|
|
125
|
+
- "endpoint": Display combined filters for an endpoint within a sync
|
|
126
|
+
- "endpoint_all": Display combined filters for an endpoint across all syncs
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
template_name = "ipfabric_netbox/inc/combined_expressions.html"
|
|
130
|
+
|
|
131
|
+
def get_context_data(self, request, **kwargs):
|
|
132
|
+
"""Override this method to provide view-specific context.
|
|
133
|
+
|
|
134
|
+
Must return a dict with standardized keys:
|
|
135
|
+
- object: The main object being displayed (IPFabricFilter or IPFabricEndpoint, or None)
|
|
136
|
+
- combined_expressions: The expressions (list for filters, dict for endpoints)
|
|
137
|
+
- context_type: One of "filter", "endpoint", or "endpoint_all"
|
|
138
|
+
- is_empty: Boolean indicating if there are no expressions
|
|
139
|
+
- sync: Optional IPFabricSync object (for endpoint context type only)
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
request: The HTTP request object
|
|
143
|
+
**kwargs: URL parameters passed to the view
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
dict: Context dictionary for template rendering
|
|
147
|
+
"""
|
|
148
|
+
raise NotImplementedError(
|
|
149
|
+
"Subclasses must implement get_context_data()"
|
|
150
|
+
) # pragma: no cover
|
|
151
|
+
|
|
152
|
+
def add_no_cache_headers(self, response):
|
|
153
|
+
"""Add cache control headers to prevent browser caching.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
response: HttpResponse object
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
HttpResponse: The response with cache headers added
|
|
160
|
+
"""
|
|
161
|
+
response["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
|
162
|
+
response["Pragma"] = "no-cache"
|
|
163
|
+
response["Expires"] = "0"
|
|
164
|
+
return response
|
|
165
|
+
|
|
166
|
+
def get(self, request, **kwargs):
|
|
167
|
+
"""Handle GET requests by rendering the template with context.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
request: The HTTP request object
|
|
171
|
+
**kwargs: URL parameters
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
HttpResponse: Rendered template with no-cache headers
|
|
175
|
+
"""
|
|
176
|
+
context = self.get_context_data(request, **kwargs)
|
|
177
|
+
response = render(request, self.template_name, context)
|
|
178
|
+
return self.add_no_cache_headers(response)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# endregion
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# region - Endpoint (Read-Only)
|
|
185
|
+
@register_model_view(IPFabricEndpoint, "list", path="", detail=False)
|
|
186
|
+
class IPFabricEndpointListView(generic.ObjectListView):
|
|
187
|
+
queryset = IPFabricEndpoint.objects.annotate(
|
|
188
|
+
filters_count=models.Count("filters", distinct=True)
|
|
189
|
+
)
|
|
190
|
+
table = IPFabricEndpointTable
|
|
191
|
+
filterset = IPFabricEndpointFilterSet
|
|
192
|
+
template_name = "ipfabric_netbox/htmx_list.html"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@register_model_view(IPFabricEndpoint)
|
|
196
|
+
class IPFabricEndpointView(GetRelatedModelsMixin, generic.ObjectView):
|
|
197
|
+
queryset = IPFabricEndpoint.objects.all()
|
|
198
|
+
actions = ()
|
|
199
|
+
|
|
200
|
+
def get_extra_context(self, request, instance):
|
|
201
|
+
return {
|
|
202
|
+
"related_models": self.get_related_models(
|
|
203
|
+
request,
|
|
204
|
+
instance,
|
|
205
|
+
extra=(
|
|
206
|
+
(
|
|
207
|
+
IPFabricFilter.objects.restrict(request.user, "view").filter(
|
|
208
|
+
endpoints=instance
|
|
209
|
+
),
|
|
210
|
+
"endpoints",
|
|
211
|
+
),
|
|
212
|
+
),
|
|
213
|
+
),
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@register_model_view(
|
|
218
|
+
IPFabricEndpoint,
|
|
219
|
+
name="filters",
|
|
220
|
+
path="filters",
|
|
221
|
+
kwargs={"model": IPFabricEndpoint},
|
|
222
|
+
)
|
|
223
|
+
class IPFabricEndpointFiltersView(CombinedExpressionsBaseView):
|
|
224
|
+
"""Display combined filters for an endpoint with optional sync selection."""
|
|
225
|
+
|
|
226
|
+
template_name = "ipfabric_netbox/inc/endpoint_filters_with_selector.html"
|
|
227
|
+
|
|
228
|
+
def get_context_data(self, request, **kwargs):
|
|
229
|
+
"""Provide context for endpoint filters with sync selection.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
request: HTTP request (must be HTMX)
|
|
233
|
+
**kwargs: Must contain 'pk' (endpoint primary key)
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
dict: Standardized context with endpoint object, available syncs,
|
|
237
|
+
and combined filters (optionally filtered by sync)
|
|
238
|
+
"""
|
|
239
|
+
endpoint_pk = kwargs.get("pk")
|
|
240
|
+
sync_pk = request.GET.get("sync_pk")
|
|
241
|
+
from_sync = request.GET.get("from_sync") == "true"
|
|
242
|
+
|
|
243
|
+
# Return empty context if not HTMX or missing required parameters
|
|
244
|
+
if not request.htmx or not endpoint_pk:
|
|
245
|
+
return {
|
|
246
|
+
"object": None,
|
|
247
|
+
"combined_expressions": {},
|
|
248
|
+
"is_empty": True,
|
|
249
|
+
"context_type": "endpoint_all",
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
# Fetch endpoint
|
|
253
|
+
endpoint_obj = IPFabricEndpoint.objects.prefetch_related("filters").get(
|
|
254
|
+
pk=endpoint_pk
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# Determine if we're filtering by sync
|
|
258
|
+
sync_obj = None
|
|
259
|
+
if sync_pk:
|
|
260
|
+
try:
|
|
261
|
+
sync_obj = IPFabricSync.objects.get(pk=sync_pk)
|
|
262
|
+
except IPFabricSync.DoesNotExist:
|
|
263
|
+
pass
|
|
264
|
+
|
|
265
|
+
# Combine filters (with optional sync filter)
|
|
266
|
+
combined = endpoint_obj.combine_filters(sync=sync_obj)
|
|
267
|
+
|
|
268
|
+
# Determine context type based on sync selection
|
|
269
|
+
context_type = "endpoint" if sync_obj else "endpoint_all"
|
|
270
|
+
|
|
271
|
+
context = {
|
|
272
|
+
"object": endpoint_obj,
|
|
273
|
+
"combined_expressions": combined,
|
|
274
|
+
"is_empty": len(combined) == 0,
|
|
275
|
+
"context_type": context_type,
|
|
276
|
+
"from_sync": from_sync,
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
# Add sync to context if one is selected
|
|
280
|
+
if sync_obj:
|
|
281
|
+
context["sync"] = sync_obj
|
|
282
|
+
|
|
283
|
+
# Only add sync selector context if not coming from a sync view
|
|
284
|
+
if not from_sync:
|
|
285
|
+
context["available_syncs"] = IPFabricSync.objects.all().order_by("name")
|
|
286
|
+
context["selected_sync_pk"] = int(sync_pk) if sync_pk else None
|
|
287
|
+
|
|
288
|
+
return context
|
|
289
|
+
|
|
290
|
+
def get(self, request, **kwargs):
|
|
291
|
+
"""Handle GET requests, using appropriate template based on context.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
request: The HTTP request object
|
|
295
|
+
**kwargs: URL parameters
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
HttpResponse: Rendered template with no-cache headers
|
|
299
|
+
"""
|
|
300
|
+
context = self.get_context_data(request, **kwargs)
|
|
301
|
+
|
|
302
|
+
# Determine which template to use
|
|
303
|
+
from_sync = request.GET.get("from_sync") == "true"
|
|
304
|
+
sync_pk_param = request.GET.get("sync_pk")
|
|
305
|
+
|
|
306
|
+
if from_sync:
|
|
307
|
+
# Coming from sync view - use simple template without selector
|
|
308
|
+
template_name = "ipfabric_netbox/inc/combined_expressions.html"
|
|
309
|
+
elif sync_pk_param is not None and not from_sync:
|
|
310
|
+
# Sync selection change via dropdown - use content-only template
|
|
311
|
+
template_name = "ipfabric_netbox/inc/combined_expressions_content.html"
|
|
312
|
+
else:
|
|
313
|
+
# Initial load from general endpoints - use template with selector
|
|
314
|
+
template_name = self.template_name
|
|
315
|
+
|
|
316
|
+
response = render(request, template_name, context)
|
|
317
|
+
return self.add_no_cache_headers(response)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# endregion
|
|
321
|
+
|
|
94
322
|
# region - Transform Map Relationship Field
|
|
95
323
|
|
|
96
324
|
|
|
@@ -116,7 +344,6 @@ class IPFabricRelationshipFieldBulkDeleteView(generic.BulkDeleteView):
|
|
|
116
344
|
table = IPFabricRelationshipFieldTable
|
|
117
345
|
|
|
118
346
|
|
|
119
|
-
# This list is not linked in navigation, but it's needed for tests
|
|
120
347
|
@register_model_view(IPFabricRelationshipField, "list", path="", detail=False)
|
|
121
348
|
class IPFabricRelationshipFieldListView(generic.ObjectListView):
|
|
122
349
|
queryset = IPFabricRelationshipField.objects.all()
|
|
@@ -242,9 +469,17 @@ class IPFabricTransformMapBulkDeleteView(generic.BulkDeleteView):
|
|
|
242
469
|
|
|
243
470
|
|
|
244
471
|
@register_model_view(IPFabricTransformMap)
|
|
245
|
-
class IPFabricTransformMapView(generic.ObjectView):
|
|
472
|
+
class IPFabricTransformMapView(GetRelatedModelsMixin, generic.ObjectView):
|
|
246
473
|
queryset = IPFabricTransformMap.objects.all()
|
|
247
474
|
|
|
475
|
+
def get_extra_context(self, request, instance):
|
|
476
|
+
return {
|
|
477
|
+
"related_models": self.get_related_models(
|
|
478
|
+
request,
|
|
479
|
+
instance,
|
|
480
|
+
),
|
|
481
|
+
}
|
|
482
|
+
|
|
248
483
|
|
|
249
484
|
@register_model_view(IPFabricTransformMap, "restore", detail=False)
|
|
250
485
|
class IPFabricTransformMapRestoreView(generic.ObjectListView):
|
|
@@ -331,7 +566,7 @@ class IPFabricTransformMapCloneView(BaseObjectView):
|
|
|
331
566
|
# Clone the transform map - create a proper copy using Django model copying
|
|
332
567
|
new_map = IPFabricTransformMap(
|
|
333
568
|
name=form.cleaned_data["name"],
|
|
334
|
-
|
|
569
|
+
source_endpoint=obj.source_endpoint,
|
|
335
570
|
target_model=obj.target_model,
|
|
336
571
|
group=form.cleaned_data["group"],
|
|
337
572
|
)
|
|
@@ -446,7 +681,6 @@ class IPFabricTransformRelationshipView(generic.ObjectChildrenView):
|
|
|
446
681
|
# region - Transform Map Field
|
|
447
682
|
|
|
448
683
|
|
|
449
|
-
# This list is not linked in navigation, but it's needed for tests
|
|
450
684
|
@register_model_view(IPFabricTransformField, "list", path="", detail=False)
|
|
451
685
|
class IPFabricTransformFieldListView(generic.ObjectListView):
|
|
452
686
|
queryset = IPFabricTransformField.objects.all()
|
|
@@ -845,7 +1079,7 @@ class IPFabricSyncBulkDeleteView(generic.BulkDeleteView):
|
|
|
845
1079
|
|
|
846
1080
|
|
|
847
1081
|
@register_model_view(IPFabricSync, "transformmaps")
|
|
848
|
-
class
|
|
1082
|
+
class IPFabricTransformMapSyncTabView(generic.ObjectChildrenView):
|
|
849
1083
|
queryset = IPFabricSync.objects.all()
|
|
850
1084
|
child_model = IPFabricTransformMap
|
|
851
1085
|
table = IPFabricTransformMapTable
|
|
@@ -865,6 +1099,105 @@ class IPFabricTransformMapTabView(generic.ObjectChildrenView):
|
|
|
865
1099
|
)
|
|
866
1100
|
|
|
867
1101
|
|
|
1102
|
+
@register_model_view(IPFabricSync, "filters")
|
|
1103
|
+
class IPFabricTransformMapFilterTabView(generic.ObjectChildrenView):
|
|
1104
|
+
queryset = IPFabricSync.objects.all()
|
|
1105
|
+
child_model = IPFabricFilter
|
|
1106
|
+
table = IPFabricFilterTable
|
|
1107
|
+
template_name = "generic/object_children.html"
|
|
1108
|
+
actions = (AddObject, BulkDelete)
|
|
1109
|
+
tab = ViewTab(
|
|
1110
|
+
label=_("Filters"),
|
|
1111
|
+
badge=lambda obj: obj.filters.count(),
|
|
1112
|
+
permission="ipfabric_netbox.view_ipfabricfilter",
|
|
1113
|
+
)
|
|
1114
|
+
|
|
1115
|
+
def get_children(self, request, parent):
|
|
1116
|
+
return parent.filters.all()
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
@register_model_view(IPFabricSync, "endpoints")
|
|
1120
|
+
class IPFabricSyncEndpointTabView(generic.ObjectChildrenView):
|
|
1121
|
+
queryset = IPFabricSync.objects.all()
|
|
1122
|
+
child_model = IPFabricEndpoint
|
|
1123
|
+
table = IPFabricEndpointTable
|
|
1124
|
+
template_name = "generic/object_children.html"
|
|
1125
|
+
actions = ()
|
|
1126
|
+
tab = ViewTab(
|
|
1127
|
+
label=_("Endpoints"),
|
|
1128
|
+
badge=lambda obj: IPFabricEndpoint.objects.filter(filters__syncs=obj)
|
|
1129
|
+
.distinct()
|
|
1130
|
+
.count(),
|
|
1131
|
+
permission="ipfabric_netbox.view_ipfabricendpoint",
|
|
1132
|
+
)
|
|
1133
|
+
|
|
1134
|
+
def get_children(self, request, parent):
|
|
1135
|
+
# Get all unique endpoints used by filters in this sync
|
|
1136
|
+
return (
|
|
1137
|
+
IPFabricEndpoint.objects.filter(filters__syncs=parent)
|
|
1138
|
+
.distinct()
|
|
1139
|
+
.annotate(
|
|
1140
|
+
sync_pk=models.Value(parent.pk, output_field=models.IntegerField()),
|
|
1141
|
+
filters_count=models.Count(
|
|
1142
|
+
"filters", filter=models.Q(filters__syncs=parent)
|
|
1143
|
+
),
|
|
1144
|
+
)
|
|
1145
|
+
)
|
|
1146
|
+
|
|
1147
|
+
def get_table(self, *args, **kwargs):
|
|
1148
|
+
table = super().get_table(*args, **kwargs)
|
|
1149
|
+
# Override default columns to show the context-specific ones
|
|
1150
|
+
table.default_columns = ("name", "endpoint", "filters_count", "show_filters")
|
|
1151
|
+
return table
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
@register_model_view(
|
|
1155
|
+
IPFabricSync,
|
|
1156
|
+
name="endpoint_filters",
|
|
1157
|
+
path="endpoint/<int:endpoint_pk>/filters",
|
|
1158
|
+
kwargs={"model": IPFabricSync},
|
|
1159
|
+
)
|
|
1160
|
+
class IPFabricSyncEndpointFiltersView(CombinedExpressionsBaseView):
|
|
1161
|
+
"""Display combined filters for an endpoint within a specific sync."""
|
|
1162
|
+
|
|
1163
|
+
def get_context_data(self, request, **kwargs):
|
|
1164
|
+
"""Provide context for endpoint filters within a sync.
|
|
1165
|
+
|
|
1166
|
+
Args:
|
|
1167
|
+
request: HTTP request (must be HTMX)
|
|
1168
|
+
**kwargs: Must contain 'pk' (sync primary key) and 'endpoint_pk'
|
|
1169
|
+
|
|
1170
|
+
Returns:
|
|
1171
|
+
dict: Standardized context with endpoint, sync, and combined filters
|
|
1172
|
+
"""
|
|
1173
|
+
sync_pk = kwargs.get("pk")
|
|
1174
|
+
endpoint_pk = kwargs.get("endpoint_pk")
|
|
1175
|
+
|
|
1176
|
+
# Return empty context if not HTMX or missing required parameters
|
|
1177
|
+
if not request.htmx or not sync_pk or not endpoint_pk:
|
|
1178
|
+
return {
|
|
1179
|
+
"object": None,
|
|
1180
|
+
"combined_expressions": {},
|
|
1181
|
+
"is_empty": True,
|
|
1182
|
+
"context_type": "endpoint",
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
# Fetch sync and endpoint objects
|
|
1186
|
+
sync_obj = IPFabricSync.objects.get(pk=sync_pk)
|
|
1187
|
+
endpoint_obj = IPFabricEndpoint.objects.get(pk=endpoint_pk)
|
|
1188
|
+
|
|
1189
|
+
# Combine filters for this endpoint within this sync
|
|
1190
|
+
combined = endpoint_obj.combine_filters(sync=sync_obj)
|
|
1191
|
+
|
|
1192
|
+
return {
|
|
1193
|
+
"object": endpoint_obj, # The endpoint is the main object
|
|
1194
|
+
"sync": sync_obj, # Pass sync for template context
|
|
1195
|
+
"combined_expressions": combined,
|
|
1196
|
+
"is_empty": len(combined) == 0,
|
|
1197
|
+
"context_type": "endpoint",
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
|
|
868
1201
|
@register_model_view(IPFabricSync, "ingestion")
|
|
869
1202
|
class IPFabricIngestionTabView(generic.ObjectChildrenView):
|
|
870
1203
|
queryset = IPFabricSync.objects.all()
|
|
@@ -1234,3 +1567,384 @@ class IPFabricTable(generic.ObjectView):
|
|
|
1234
1567
|
|
|
1235
1568
|
|
|
1236
1569
|
# endregion
|
|
1570
|
+
|
|
1571
|
+
|
|
1572
|
+
# region - Filter
|
|
1573
|
+
@register_model_view(IPFabricFilter, "list", path="", detail=False)
|
|
1574
|
+
class IPFabricFilterListView(generic.ObjectListView):
|
|
1575
|
+
queryset = IPFabricFilter.objects.all()
|
|
1576
|
+
table = IPFabricFilterTable
|
|
1577
|
+
filterset = IPFabricFilterFilterSet
|
|
1578
|
+
template_name = "ipfabric_netbox/htmx_list.html"
|
|
1579
|
+
|
|
1580
|
+
|
|
1581
|
+
@register_model_view(IPFabricFilter, "add", detail=False)
|
|
1582
|
+
@register_model_view(IPFabricFilter, "edit")
|
|
1583
|
+
class IPFabricFilterEditView(generic.ObjectEditView):
|
|
1584
|
+
queryset = IPFabricFilter.objects.all()
|
|
1585
|
+
form = IPFabricFilterForm
|
|
1586
|
+
default_return_url = "plugins:ipfabric_netbox:ipfabricfilter_list"
|
|
1587
|
+
|
|
1588
|
+
|
|
1589
|
+
@register_model_view(IPFabricFilter, "delete")
|
|
1590
|
+
class IPFabricFilterDeleteView(generic.ObjectDeleteView):
|
|
1591
|
+
queryset = IPFabricFilter.objects.all()
|
|
1592
|
+
default_return_url = "plugins:ipfabric_netbox:ipfabricfilter_list"
|
|
1593
|
+
|
|
1594
|
+
|
|
1595
|
+
@register_model_view(IPFabricFilter, "bulk_import", path="import", detail=False)
|
|
1596
|
+
class IPFabricFilterBulkImportView(generic.BulkImportView):
|
|
1597
|
+
queryset = IPFabricFilter.objects.all()
|
|
1598
|
+
model_form = IPFabricFilterBulkImportForm
|
|
1599
|
+
|
|
1600
|
+
|
|
1601
|
+
@register_model_view(IPFabricFilter, "bulk_edit", path="edit", detail=False)
|
|
1602
|
+
class IPFabricFilterBulkEditView(generic.BulkEditView):
|
|
1603
|
+
queryset = IPFabricFilter.objects.all()
|
|
1604
|
+
table = IPFabricFilterTable
|
|
1605
|
+
form = IPFabricFilterBulkEditForm
|
|
1606
|
+
|
|
1607
|
+
|
|
1608
|
+
@register_model_view(IPFabricFilter, "bulk_rename", path="rename", detail=False)
|
|
1609
|
+
class IPFabricFilterBulkRenameView(generic.BulkRenameView):
|
|
1610
|
+
queryset = IPFabricFilter.objects.all()
|
|
1611
|
+
|
|
1612
|
+
|
|
1613
|
+
@register_model_view(IPFabricFilter, "bulk_delete", path="delete", detail=False)
|
|
1614
|
+
class IPFabricFilterBulkDeleteView(generic.BulkDeleteView):
|
|
1615
|
+
queryset = IPFabricFilter.objects.all()
|
|
1616
|
+
table = IPFabricFilterTable
|
|
1617
|
+
|
|
1618
|
+
|
|
1619
|
+
@register_model_view(IPFabricFilter)
|
|
1620
|
+
class IPFabricFilterView(GetRelatedModelsMixin, generic.ObjectView):
|
|
1621
|
+
queryset = IPFabricFilter.objects.all()
|
|
1622
|
+
|
|
1623
|
+
def get_extra_context(self, request, instance):
|
|
1624
|
+
return {
|
|
1625
|
+
"related_models": self.get_related_models(
|
|
1626
|
+
request,
|
|
1627
|
+
instance,
|
|
1628
|
+
extra=(
|
|
1629
|
+
(
|
|
1630
|
+
IPFabricSync.objects.restrict(request.user, "view").filter(
|
|
1631
|
+
filters__in=[instance]
|
|
1632
|
+
),
|
|
1633
|
+
"filter_id",
|
|
1634
|
+
),
|
|
1635
|
+
(
|
|
1636
|
+
IPFabricEndpoint.objects.restrict(request.user, "view").filter(
|
|
1637
|
+
filters__in=[instance]
|
|
1638
|
+
),
|
|
1639
|
+
"ipfabric_filter_id",
|
|
1640
|
+
),
|
|
1641
|
+
(
|
|
1642
|
+
IPFabricFilterExpression.objects.restrict(
|
|
1643
|
+
request.user, "view"
|
|
1644
|
+
).filter(filters__in=[instance]),
|
|
1645
|
+
"ipfabric_filter_id",
|
|
1646
|
+
),
|
|
1647
|
+
),
|
|
1648
|
+
),
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
|
|
1652
|
+
@register_model_view(
|
|
1653
|
+
IPFabricFilter,
|
|
1654
|
+
name="combined_expressions",
|
|
1655
|
+
path="combined-expressions",
|
|
1656
|
+
kwargs={"model": IPFabricFilter},
|
|
1657
|
+
)
|
|
1658
|
+
class IPFabricFilterCombinedExpressionsView(CombinedExpressionsBaseView):
|
|
1659
|
+
"""Display merged expressions for a single filter."""
|
|
1660
|
+
|
|
1661
|
+
def get_context_data(self, request, **kwargs):
|
|
1662
|
+
"""Provide context for filter's merged expressions.
|
|
1663
|
+
|
|
1664
|
+
Args:
|
|
1665
|
+
request: HTTP request (must be HTMX)
|
|
1666
|
+
**kwargs: Must contain 'pk' (filter primary key)
|
|
1667
|
+
|
|
1668
|
+
Returns:
|
|
1669
|
+
dict: Standardized context with filter object and merged expressions
|
|
1670
|
+
"""
|
|
1671
|
+
filter_pk = kwargs.get("pk")
|
|
1672
|
+
|
|
1673
|
+
# Return empty context if not HTMX or missing required parameters
|
|
1674
|
+
if not request.htmx or not filter_pk:
|
|
1675
|
+
return {
|
|
1676
|
+
"object": None,
|
|
1677
|
+
"combined_expressions": [],
|
|
1678
|
+
"is_empty": True,
|
|
1679
|
+
"context_type": "filter",
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
# Force fresh query from database, prefetch expressions
|
|
1683
|
+
filter_obj = IPFabricFilter.objects.prefetch_related("expressions").get(
|
|
1684
|
+
pk=filter_pk
|
|
1685
|
+
)
|
|
1686
|
+
combined = filter_obj.merge_expressions()
|
|
1687
|
+
|
|
1688
|
+
return {
|
|
1689
|
+
"object": filter_obj,
|
|
1690
|
+
"combined_expressions": combined,
|
|
1691
|
+
"is_empty": len(combined) == 0,
|
|
1692
|
+
"context_type": "filter",
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
|
|
1696
|
+
# endregion
|
|
1697
|
+
|
|
1698
|
+
|
|
1699
|
+
# region - Filter Expression
|
|
1700
|
+
@register_model_view(IPFabricFilterExpression, "list", path="", detail=False)
|
|
1701
|
+
class IPFabricFilterExpressionListView(generic.ObjectListView):
|
|
1702
|
+
queryset = IPFabricFilterExpression.objects.all()
|
|
1703
|
+
table = IPFabricFilterExpressionTable
|
|
1704
|
+
filterset = IPFabricFilterExpressionFilterSet
|
|
1705
|
+
|
|
1706
|
+
|
|
1707
|
+
@register_model_view(IPFabricFilterExpression, "add", detail=False)
|
|
1708
|
+
@register_model_view(IPFabricFilterExpression, "edit")
|
|
1709
|
+
class IPFabricFilterExpressionEditView(generic.ObjectEditView):
|
|
1710
|
+
queryset = IPFabricFilterExpression.objects.all()
|
|
1711
|
+
form = IPFabricFilterExpressionForm
|
|
1712
|
+
default_return_url = "plugins:ipfabric_netbox:ipfabricfilterexpression_list"
|
|
1713
|
+
template_name = "ipfabric_netbox/ipfabricfilterexpression_edit.html"
|
|
1714
|
+
|
|
1715
|
+
def get_extra_context(self, request, instance):
|
|
1716
|
+
"""Add test configuration context to the template."""
|
|
1717
|
+
context = super().get_extra_context(request, instance)
|
|
1718
|
+
|
|
1719
|
+
# Add test-related context if editing existing object
|
|
1720
|
+
if instance.pk:
|
|
1721
|
+
# Get available test sources and endpoints
|
|
1722
|
+
sources, endpoints = get_filter_expression_test_candidates(instance)
|
|
1723
|
+
context["available_test_sources"] = list(sources)
|
|
1724
|
+
context["available_test_endpoints"] = list(endpoints)
|
|
1725
|
+
context["can_test"] = bool(sources and endpoints)
|
|
1726
|
+
else:
|
|
1727
|
+
context["can_test"] = False
|
|
1728
|
+
|
|
1729
|
+
return context
|
|
1730
|
+
|
|
1731
|
+
|
|
1732
|
+
@register_model_view(IPFabricFilterExpression, "delete")
|
|
1733
|
+
class IPFabricFilterExpressionDeleteView(generic.ObjectDeleteView):
|
|
1734
|
+
queryset = IPFabricFilterExpression.objects.all()
|
|
1735
|
+
default_return_url = "plugins:ipfabric_netbox:ipfabricfilterexpression_list"
|
|
1736
|
+
|
|
1737
|
+
|
|
1738
|
+
@register_model_view(
|
|
1739
|
+
IPFabricFilterExpression, "bulk_import", path="import", detail=False
|
|
1740
|
+
)
|
|
1741
|
+
class IPFabricFilterExpressionBulkImportView(generic.BulkImportView):
|
|
1742
|
+
queryset = IPFabricFilterExpression.objects.all()
|
|
1743
|
+
model_form = IPFabricFilterExpressionBulkImportForm
|
|
1744
|
+
|
|
1745
|
+
|
|
1746
|
+
@register_model_view(IPFabricFilterExpression, "bulk_edit", path="edit", detail=False)
|
|
1747
|
+
class IPFabricFilterExpressionBulkEditView(generic.BulkEditView):
|
|
1748
|
+
queryset = IPFabricFilterExpression.objects.all()
|
|
1749
|
+
table = IPFabricFilterExpressionTable
|
|
1750
|
+
form = IPFabricFilterExpressionBulkEditForm
|
|
1751
|
+
|
|
1752
|
+
|
|
1753
|
+
@register_model_view(
|
|
1754
|
+
IPFabricFilterExpression, "bulk_rename", path="rename", detail=False
|
|
1755
|
+
)
|
|
1756
|
+
class IPFabricFilterExpressionBulkRenameView(generic.BulkRenameView):
|
|
1757
|
+
queryset = IPFabricFilterExpression.objects.all()
|
|
1758
|
+
|
|
1759
|
+
|
|
1760
|
+
@register_model_view(
|
|
1761
|
+
IPFabricFilterExpression, "bulk_delete", path="delete", detail=False
|
|
1762
|
+
)
|
|
1763
|
+
class IPFabricFilterExpressionBulkDeleteView(generic.BulkDeleteView):
|
|
1764
|
+
queryset = IPFabricFilterExpression.objects.all()
|
|
1765
|
+
table = IPFabricFilterExpressionTable
|
|
1766
|
+
|
|
1767
|
+
|
|
1768
|
+
@register_model_view(IPFabricFilterExpression)
|
|
1769
|
+
class IPFabricFilterExpressionView(GetRelatedModelsMixin, generic.ObjectView):
|
|
1770
|
+
queryset = IPFabricFilterExpression.objects.all()
|
|
1771
|
+
|
|
1772
|
+
def get_extra_context(self, request, instance):
|
|
1773
|
+
return {
|
|
1774
|
+
"related_models": self.get_related_models(
|
|
1775
|
+
request,
|
|
1776
|
+
instance,
|
|
1777
|
+
extra=(
|
|
1778
|
+
(
|
|
1779
|
+
IPFabricFilter.objects.restrict(request.user, "view").filter(
|
|
1780
|
+
expressions__in=[instance]
|
|
1781
|
+
),
|
|
1782
|
+
"expression_id",
|
|
1783
|
+
),
|
|
1784
|
+
),
|
|
1785
|
+
),
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
|
|
1789
|
+
@register_model_view(IPFabricFilterExpression, "test")
|
|
1790
|
+
class IPFabricFilterExpressionTestView(View):
|
|
1791
|
+
"""Test a filter expression against IP Fabric API."""
|
|
1792
|
+
|
|
1793
|
+
def post(self, request, pk=None):
|
|
1794
|
+
# Get test parameters from POST data
|
|
1795
|
+
test_source_id = request.POST.get("test_source")
|
|
1796
|
+
test_endpoint_id = request.POST.get("test_endpoint")
|
|
1797
|
+
expression_json = request.POST.get("expression")
|
|
1798
|
+
|
|
1799
|
+
return self._test_expression_logic(
|
|
1800
|
+
request, test_source_id, test_endpoint_id, expression_json
|
|
1801
|
+
)
|
|
1802
|
+
|
|
1803
|
+
@staticmethod
|
|
1804
|
+
def _test_expression_logic(
|
|
1805
|
+
request, test_source_id, test_endpoint_id, expression_json
|
|
1806
|
+
):
|
|
1807
|
+
"""Shared logic for testing filter expressions (works for both saved and unsaved)."""
|
|
1808
|
+
|
|
1809
|
+
# Validate required fields
|
|
1810
|
+
if not test_source_id:
|
|
1811
|
+
return HttpResponse(
|
|
1812
|
+
json.dumps({"success": False, "error": "Please select a Test Source."}),
|
|
1813
|
+
content_type="application/json",
|
|
1814
|
+
)
|
|
1815
|
+
|
|
1816
|
+
if not test_endpoint_id:
|
|
1817
|
+
return HttpResponse(
|
|
1818
|
+
json.dumps(
|
|
1819
|
+
{"success": False, "error": "Please select a Test Endpoint."}
|
|
1820
|
+
),
|
|
1821
|
+
content_type="application/json",
|
|
1822
|
+
)
|
|
1823
|
+
|
|
1824
|
+
# Validate expression JSON
|
|
1825
|
+
if expression_json:
|
|
1826
|
+
try:
|
|
1827
|
+
expression_data = json.loads(expression_json)
|
|
1828
|
+
if not isinstance(expression_data, list):
|
|
1829
|
+
return HttpResponse(
|
|
1830
|
+
json.dumps(
|
|
1831
|
+
{
|
|
1832
|
+
"success": False,
|
|
1833
|
+
"error": "Expression must be a JSON list.",
|
|
1834
|
+
}
|
|
1835
|
+
),
|
|
1836
|
+
content_type="application/json",
|
|
1837
|
+
)
|
|
1838
|
+
for idx, item in enumerate(expression_data):
|
|
1839
|
+
if not isinstance(item, dict):
|
|
1840
|
+
return HttpResponse(
|
|
1841
|
+
json.dumps(
|
|
1842
|
+
{
|
|
1843
|
+
"success": False,
|
|
1844
|
+
"error": f"Expression item at index {idx} must be a dictionary.",
|
|
1845
|
+
}
|
|
1846
|
+
),
|
|
1847
|
+
content_type="application/json",
|
|
1848
|
+
)
|
|
1849
|
+
except json.JSONDecodeError as e:
|
|
1850
|
+
return HttpResponse(
|
|
1851
|
+
json.dumps({"success": False, "error": f"Invalid JSON: {str(e)}"}),
|
|
1852
|
+
content_type="application/json",
|
|
1853
|
+
)
|
|
1854
|
+
else:
|
|
1855
|
+
return HttpResponse(
|
|
1856
|
+
json.dumps({"success": False, "error": "Expression is required."}),
|
|
1857
|
+
content_type="application/json",
|
|
1858
|
+
)
|
|
1859
|
+
|
|
1860
|
+
# Get source and endpoint objects
|
|
1861
|
+
try:
|
|
1862
|
+
source = IPFabricSource.objects.get(pk=test_source_id)
|
|
1863
|
+
except IPFabricSource.DoesNotExist:
|
|
1864
|
+
return HttpResponse(
|
|
1865
|
+
json.dumps({"success": False, "error": "Selected source not found."}),
|
|
1866
|
+
content_type="application/json",
|
|
1867
|
+
status=404,
|
|
1868
|
+
)
|
|
1869
|
+
|
|
1870
|
+
# Check if source is LOCAL
|
|
1871
|
+
if source.type != IPFabricSourceTypeChoices.LOCAL:
|
|
1872
|
+
return HttpResponse(
|
|
1873
|
+
json.dumps(
|
|
1874
|
+
{
|
|
1875
|
+
"success": False,
|
|
1876
|
+
"error": "Cannot test against REMOTE sources. Please select a LOCAL IP Fabric source with direct API access.",
|
|
1877
|
+
}
|
|
1878
|
+
),
|
|
1879
|
+
content_type="application/json",
|
|
1880
|
+
)
|
|
1881
|
+
|
|
1882
|
+
try:
|
|
1883
|
+
endpoint = IPFabricEndpoint.objects.get(pk=test_endpoint_id)
|
|
1884
|
+
except IPFabricEndpoint.DoesNotExist:
|
|
1885
|
+
return HttpResponse(
|
|
1886
|
+
json.dumps({"success": False, "error": "Selected endpoint not found."}),
|
|
1887
|
+
content_type="application/json",
|
|
1888
|
+
status=404,
|
|
1889
|
+
)
|
|
1890
|
+
|
|
1891
|
+
# Test the expression against IP Fabric API
|
|
1892
|
+
try:
|
|
1893
|
+
# Check that source has auth token
|
|
1894
|
+
auth = source.parameters.get("auth")
|
|
1895
|
+
if not auth:
|
|
1896
|
+
return HttpResponse(
|
|
1897
|
+
json.dumps(
|
|
1898
|
+
{
|
|
1899
|
+
"success": False,
|
|
1900
|
+
"error": "Source is missing API authentication token.",
|
|
1901
|
+
}
|
|
1902
|
+
),
|
|
1903
|
+
content_type="application/json",
|
|
1904
|
+
)
|
|
1905
|
+
|
|
1906
|
+
# Prepare parameters for IPFabric client (same pattern as other views)
|
|
1907
|
+
parameters = source.parameters.copy()
|
|
1908
|
+
parameters.update(
|
|
1909
|
+
{
|
|
1910
|
+
"snapshot_id": "$last",
|
|
1911
|
+
"base_url": source.url,
|
|
1912
|
+
"pagination": {"limit": 1, "start": 0},
|
|
1913
|
+
}
|
|
1914
|
+
)
|
|
1915
|
+
# Build filter - use AND type as default (IPF doesn't distinguish between types at this level)
|
|
1916
|
+
filter_dict = {"and": expression_data}
|
|
1917
|
+
ipf = IPFabric(parameters=parameters)
|
|
1918
|
+
results = ipf.ipf.fetch_all(
|
|
1919
|
+
url=endpoint.endpoint, snapshot_id="$last", filters=filter_dict
|
|
1920
|
+
)
|
|
1921
|
+
|
|
1922
|
+
# Return success
|
|
1923
|
+
return HttpResponse(
|
|
1924
|
+
json.dumps(
|
|
1925
|
+
{
|
|
1926
|
+
"success": True,
|
|
1927
|
+
"count": len(results),
|
|
1928
|
+
"message": f"Test successful! Expression matched {len(results)} result(s) (limited to 1 for testing).",
|
|
1929
|
+
}
|
|
1930
|
+
),
|
|
1931
|
+
content_type="application/json",
|
|
1932
|
+
)
|
|
1933
|
+
|
|
1934
|
+
except Exception as e:
|
|
1935
|
+
# Return error with helpful context
|
|
1936
|
+
error_msg = str(e)
|
|
1937
|
+
|
|
1938
|
+
# Enhance error message for common issues
|
|
1939
|
+
if "Unrecognized key(s)" in error_msg:
|
|
1940
|
+
error_msg += f" | Hint: The field name in your expression doesn't exist in the '{endpoint.endpoint}' endpoint. Check the IP Fabric API documentation for valid field names for this table."
|
|
1941
|
+
elif "Invalid Input" in error_msg or "VALIDATION_FAILED" in error_msg:
|
|
1942
|
+
error_msg += f" | Hint: Check that your filter syntax is correct and field names are valid for endpoint '{endpoint.endpoint}'."
|
|
1943
|
+
|
|
1944
|
+
return HttpResponse(
|
|
1945
|
+
json.dumps({"success": False, "error": f"API Error: {error_msg}"}),
|
|
1946
|
+
content_type="application/json",
|
|
1947
|
+
)
|
|
1948
|
+
|
|
1949
|
+
|
|
1950
|
+
# endregion
|