netbox-inventree-sync 0.1.0__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.
Files changed (54) hide show
  1. netbox_inventree_sync/__init__.py +42 -0
  2. netbox_inventree_sync/api/__init__.py +8 -0
  3. netbox_inventree_sync/api/exceptions.py +19 -0
  4. netbox_inventree_sync/api/serializers.py +253 -0
  5. netbox_inventree_sync/api/urls.py +25 -0
  6. netbox_inventree_sync/api/views.py +185 -0
  7. netbox_inventree_sync/choices.py +28 -0
  8. netbox_inventree_sync/filtersets.py +104 -0
  9. netbox_inventree_sync/forms.py +194 -0
  10. netbox_inventree_sync/graphql/__init__.py +18 -0
  11. netbox_inventree_sync/graphql/filters.py +31 -0
  12. netbox_inventree_sync/graphql/schema.py +19 -0
  13. netbox_inventree_sync/graphql/types.py +51 -0
  14. netbox_inventree_sync/jobs.py +114 -0
  15. netbox_inventree_sync/management/__init__.py +0 -0
  16. netbox_inventree_sync/management/commands/__init__.py +0 -0
  17. netbox_inventree_sync/management/commands/inventree_sync.py +167 -0
  18. netbox_inventree_sync/management/commands/seed_starter_mappings.py +198 -0
  19. netbox_inventree_sync/migrations/0001_initial.py +140 -0
  20. netbox_inventree_sync/migrations/0002_field_validators_and_help_text.py +17 -0
  21. netbox_inventree_sync/migrations/__init__.py +0 -0
  22. netbox_inventree_sync/models.py +310 -0
  23. netbox_inventree_sync/navigation.py +51 -0
  24. netbox_inventree_sync/search.py +37 -0
  25. netbox_inventree_sync/signals.py +258 -0
  26. netbox_inventree_sync/sync/__init__.py +13 -0
  27. netbox_inventree_sync/sync/client.py +237 -0
  28. netbox_inventree_sync/sync/compatibility.py +60 -0
  29. netbox_inventree_sync/sync/engine.py +671 -0
  30. netbox_inventree_sync/sync/fields.py +203 -0
  31. netbox_inventree_sync/sync/payload.py +47 -0
  32. netbox_inventree_sync/sync/validation.py +295 -0
  33. netbox_inventree_sync/tables.py +322 -0
  34. netbox_inventree_sync/template_content.py +102 -0
  35. netbox_inventree_sync/tests/__init__.py +0 -0
  36. netbox_inventree_sync/tests/test_api.py +589 -0
  37. netbox_inventree_sync/tests/test_builder_ui.py +264 -0
  38. netbox_inventree_sync/tests/test_client.py +377 -0
  39. netbox_inventree_sync/tests/test_compatibility.py +158 -0
  40. netbox_inventree_sync/tests/test_engine.py +452 -0
  41. netbox_inventree_sync/tests/test_extras.py +285 -0
  42. netbox_inventree_sync/tests/test_fields.py +106 -0
  43. netbox_inventree_sync/tests/test_jobs.py +470 -0
  44. netbox_inventree_sync/tests/test_models.py +241 -0
  45. netbox_inventree_sync/tests/test_pointer_resolution.py +624 -0
  46. netbox_inventree_sync/tests/test_ui.py +531 -0
  47. netbox_inventree_sync/tests/test_validation.py +382 -0
  48. netbox_inventree_sync/urls.py +33 -0
  49. netbox_inventree_sync/views.py +630 -0
  50. netbox_inventree_sync-0.1.0.dist-info/METADATA +356 -0
  51. netbox_inventree_sync-0.1.0.dist-info/RECORD +54 -0
  52. netbox_inventree_sync-0.1.0.dist-info/WHEEL +5 -0
  53. netbox_inventree_sync-0.1.0.dist-info/licenses/LICENSE +21 -0
  54. netbox_inventree_sync-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,42 @@
1
+ """NetBox plugin: synchronize NetBox inventory data to InvenTree."""
2
+
3
+ import logging
4
+
5
+ from netbox.plugins import PluginConfig
6
+
7
+ __version__ = '0.1.0'
8
+
9
+ logger = logging.getLogger('netbox_inventree_sync')
10
+
11
+
12
+ class NetBoxInvenTreeSyncConfig(PluginConfig):
13
+ name = 'netbox_inventree_sync'
14
+ verbose_name = 'InvenTree Sync'
15
+ description = 'Synchronize NetBox inventory data to InvenTree'
16
+ version = __version__
17
+ min_version = '4.7'
18
+ menu = 'navigation.menu'
19
+
20
+ def ready(self):
21
+ # Connect the sync signal wiring. This only connects signal hooks
22
+ # (and may run one guarded binding build when apps are already
23
+ # populated — during a plain web/worker boot they are, so a
24
+ # single exception-guarded query happens here). At first boot (or
25
+ # while `manage.py test` builds the test database) the plugin's
26
+ # tables may not even exist, hence the guard. Whatever bindings
27
+ # are missed here converge later, on post_migrate, on the first
28
+ # request in the process (TTL re-check), or on the next mapping
29
+ # save — see signals.register_receivers(). Anything unexpected at
30
+ # boot must not take NetBox down.
31
+ super().ready()
32
+ try:
33
+ from netbox_inventree_sync import signals
34
+ signals.register_receivers()
35
+ except Exception:
36
+ logger.exception(
37
+ 'netbox_inventree_sync: signal receiver hooks not connected; '
38
+ 'they will be connected on the next migrate or first request'
39
+ )
40
+
41
+
42
+ config = NetBoxInvenTreeSyncConfig
@@ -0,0 +1,8 @@
1
+ """REST API components for the InvenTree sync plugin.
2
+
3
+ NetBox discovers a plugin's REST API by convention: it imports
4
+ ``netbox_inventree_sync.api.urls`` and mounts its ``urlpatterns`` under
5
+ ``/api/plugins/netbox_inventree_sync/`` (see
6
+ ``netbox/plugins/urls.py``). The submodules here follow NetBox's
7
+ recommended plugin API layout (``docs/plugins/development/rest-api.md``).
8
+ """
@@ -0,0 +1,19 @@
1
+ """API-layer exceptions shared by the plugin's serializers and views.
2
+
3
+ Kept in its own module so both the view layer (fields endpoint) and the
4
+ serializer layer (mapping writes) translate an unreachable InvenTree
5
+ into the same 502 semantics without importing each other.
6
+ """
7
+
8
+ from rest_framework import status
9
+ from rest_framework.exceptions import APIException
10
+
11
+ __all__ = ('InvenTreeUnavailable',)
12
+
13
+
14
+ class InvenTreeUnavailable(APIException):
15
+ """The InvenTree server could not be queried for endpoint metadata."""
16
+
17
+ status_code = status.HTTP_502_BAD_GATEWAY
18
+ default_detail = 'The InvenTree server could not be reached.'
19
+ default_code = 'inventree_unavailable'
@@ -0,0 +1,253 @@
1
+ """API serializers for the InvenTree sync plugin models.
2
+
3
+ Highlights:
4
+
5
+ * ``InvenTreeInstanceSerializer`` exposes the model's ``url`` column as
6
+ ``server_url`` — the inherited ``url`` field is the object's API
7
+ identity (NetBox convention) and would otherwise shadow it — and keeps
8
+ ``api_token`` strictly write-only.
9
+ * ``SyncMappingSerializer`` carries a nested, writable ``field_pairs``
10
+ list. Its ``validate()`` runs :func:`~netbox_inventree_sync.sync.validation.validate_mapping`
11
+ on unsaved :class:`~netbox_inventree_sync.models.SyncFieldPair`
12
+ instances built from the posted data, so an incompatible mapping is
13
+ rejected with HTTP 400 (carrying the validator's error codes) instead
14
+ of ever reaching the sync engine; it then runs
15
+ :func:`~netbox_inventree_sync.sync.validation.validate_mapping_graph_for_write`
16
+ so a pair set closing a pairing cycle is rejected the same way. An
17
+ InvenTree unreachable during either check surfaces as the shared 502
18
+ :class:`~netbox_inventree_sync.api.exceptions.InvenTreeUnavailable`
19
+ (the failure is upstream, not in the request). On success each pair's
20
+ ``transform`` is auto-set by the validator. When ``field_pairs`` is
21
+ present on a write it replaces the mapping's full pair set; when
22
+ absent (e.g. a PATCH) the existing pairs are left untouched.
23
+ """
24
+
25
+ from django.contrib.contenttypes.models import ContentType
26
+ from django.core.exceptions import ValidationError as DjangoValidationError
27
+ from django.db import transaction
28
+ from rest_framework import serializers
29
+ from rest_framework.exceptions import ErrorDetail, ValidationError
30
+ from rest_framework.settings import api_settings
31
+
32
+ from netbox.api.fields import ContentTypeField
33
+ from netbox.api.serializers import NetBoxModelSerializer
34
+ from netbox_inventree_sync.api.exceptions import InvenTreeUnavailable
35
+ from netbox_inventree_sync.models import (
36
+ InvenTreeInstance, SyncFieldPair, SyncLogEntry, SyncMapping, SyncedObject,
37
+ )
38
+ from netbox_inventree_sync.sync.client import InvenTreeClientError
39
+ from netbox_inventree_sync.sync.validation import (
40
+ validate_mapping,
41
+ validate_mapping_graph_for_write,
42
+ )
43
+
44
+ __all__ = (
45
+ 'InvenTreeInstanceSerializer',
46
+ 'SyncFieldPairSerializer',
47
+ 'SyncLogEntrySerializer',
48
+ 'SyncMappingSerializer',
49
+ 'SyncedObjectSerializer',
50
+ )
51
+
52
+
53
+ class InvenTreeInstanceSerializer(NetBoxModelSerializer):
54
+ server_url = serializers.URLField(
55
+ source='url',
56
+ label='Server URL',
57
+ help_text="Base URL of the InvenTree server, e.g. https://inventree.example.com",
58
+ )
59
+ api_token = serializers.CharField(
60
+ write_only=True,
61
+ max_length=255,
62
+ help_text='InvenTree API token; accepted on write, never returned on read',
63
+ )
64
+
65
+ class Meta:
66
+ model = InvenTreeInstance
67
+ fields = ('id', 'url', 'display', 'name', 'server_url', 'api_token', 'enabled')
68
+ brief_fields = ('id', 'url', 'display', 'name', 'enabled')
69
+
70
+
71
+ class SyncFieldPairSerializer(serializers.ModelSerializer):
72
+ """One NetBox → InvenTree field assignment of a mapping.
73
+
74
+ Deliberately a plain DRF ``ModelSerializer``: NetBox's
75
+ ``ValidatedModelSerializer`` would ``full_clean()`` a freshly built
76
+ (unsaved, mapping-less) pair instance and reject it long before the
77
+ mapping serializer's ``validate()`` can run ``validate_mapping``.
78
+ Pairs are persisted by the mapping serializer, which is also why
79
+ ``transform`` is read-only — it is computed by ``validate_mapping``.
80
+ """
81
+
82
+ class Meta:
83
+ model = SyncFieldPair
84
+ fields = ('id', 'netbox_field', 'netbox_subfield', 'inventree_field', 'transform')
85
+ read_only_fields = ('id', 'transform')
86
+
87
+
88
+ class SyncMappingSerializer(NetBoxModelSerializer):
89
+ connection = InvenTreeInstanceSerializer(nested=True)
90
+ netbox_content_type = ContentTypeField(
91
+ queryset=ContentType.objects.all(),
92
+ )
93
+ field_pairs = SyncFieldPairSerializer(
94
+ many=True,
95
+ required=False,
96
+ help_text='Full pair set of the mapping; replaces all existing pairs when present',
97
+ )
98
+
99
+ class Meta:
100
+ model = SyncMapping
101
+ fields = (
102
+ 'id', 'url', 'display', 'name', 'connection', 'netbox_content_type',
103
+ 'inventree_endpoint', 'match_netbox_field', 'match_inventree_field',
104
+ 'reconcile_interval_minutes', 'enabled', 'description', 'field_pairs',
105
+ )
106
+ brief_fields = ('id', 'url', 'display', 'name', 'enabled')
107
+
108
+ def validate(self, data):
109
+ pairs_data = data.get('field_pairs')
110
+ # The provisional mapping mirrors NetBox's own
111
+ # ValidatedModelSerializer: unsaved on create, the persisted
112
+ # instance patched with the posted attrs on update.
113
+ mapping = self.instance or SyncMapping()
114
+ for name, value in data.items():
115
+ if name != 'field_pairs':
116
+ setattr(mapping, name, value)
117
+ pairs = None
118
+ if pairs_data is not None:
119
+ # Validate the posted pairs against live NetBox introspection
120
+ # and the endpoint's OPTIONS metadata (the InvenTree side is
121
+ # stubbed in tests via sync.validation.InvenTreeClient).
122
+ pairs = [SyncFieldPair(**attrs) for attrs in pairs_data]
123
+ try:
124
+ pairs = validate_mapping(mapping, pairs)
125
+ except InvenTreeClientError as exc:
126
+ # Upstream failure while validating: 502, exactly like the
127
+ # fields endpoint — the request is fine, InvenTree is not.
128
+ raise InvenTreeUnavailable(str(exc))
129
+ except DjangoValidationError as exc:
130
+ raise self._drf_validation_error(exc)
131
+ data = {**data, 'field_pairs': pairs}
132
+ if pairs_data is not None or 'enabled' in data:
133
+ # Pairing-graph gate: a write that can change the graph (a new
134
+ # pair set adds edges; an enabled flip adds/removes a node)
135
+ # must not leave it cyclic.
136
+ try:
137
+ validate_mapping_graph_for_write(mapping, pairs)
138
+ except InvenTreeClientError as exc:
139
+ raise InvenTreeUnavailable(str(exc))
140
+ except DjangoValidationError as exc:
141
+ raise self._drf_validation_error(exc)
142
+ return super().validate(data)
143
+
144
+ def create(self, validated_data):
145
+ pairs = validated_data.pop('field_pairs', [])
146
+ instance = super().create(validated_data)
147
+ self._save_pairs(instance, pairs)
148
+ return instance
149
+
150
+ def update(self, instance, validated_data):
151
+ pairs = validated_data.pop('field_pairs', None)
152
+ instance = super().update(instance, validated_data)
153
+ if pairs is not None:
154
+ self._save_pairs(instance, pairs)
155
+ return instance
156
+
157
+ @staticmethod
158
+ def _save_pairs(mapping, pairs):
159
+ """Persist ``pairs`` as the mapping's complete pair set, atomically.
160
+
161
+ The full replace (delete-then-create) is one transaction so a
162
+ failure midway cannot leave the mapping with half its pairs.
163
+ """
164
+ with transaction.atomic():
165
+ mapping.field_pairs.all().delete()
166
+ for pair in pairs:
167
+ pair.pk = None
168
+ pair.mapping = mapping
169
+ pair.save()
170
+
171
+ @staticmethod
172
+ def _drf_validation_error(exc):
173
+ """Translate Django's coded ``ValidationError`` into a DRF one.
174
+
175
+ Keeps both message and code per error, so API consumers can
176
+ branch on the validator's codes (incompatible,
177
+ duplicate_target, unknown_field, pairing_missing,
178
+ pairing_ambiguous, subfield_required, cycle) the same way the
179
+ UI will.
180
+ """
181
+ if hasattr(exc, 'error_dict'):
182
+ details = {
183
+ key: [ErrorDetail(str(error), code=error.code) for error in errors]
184
+ for key, errors in exc.error_dict.items()
185
+ }
186
+ else:
187
+ details = [ErrorDetail(str(error), code=error.code) for error in exc.error_list]
188
+ return ValidationError({api_settings.NON_FIELD_ERRORS_KEY: details})
189
+
190
+
191
+ class SyncedObjectSerializer(NetBoxModelSerializer):
192
+ mapping = SyncMappingSerializer(nested=True, read_only=True)
193
+
194
+ class Meta:
195
+ model = SyncedObject
196
+ fields = (
197
+ 'id', 'url', 'display', 'mapping', 'netbox_object_id', 'inventree_pk',
198
+ 'status', 'last_synced_at', 'last_payload_hash', 'last_error',
199
+ )
200
+ brief_fields = ('id', 'url', 'display', 'netbox_object_id', 'status')
201
+
202
+
203
+ class SyncLogEntrySerializer(NetBoxModelSerializer):
204
+ synced = SyncedObjectSerializer(nested=True, allow_null=True, required=False)
205
+
206
+ class Meta:
207
+ model = SyncLogEntry
208
+ fields = (
209
+ 'id', 'url', 'display', 'synced', 'action', 'payload',
210
+ 'response_status', 'response_body', 'created',
211
+ )
212
+ brief_fields = ('id', 'url', 'display', 'action')
213
+
214
+
215
+ # --- fields endpoint (schema documentation) -----------------------------------
216
+
217
+
218
+ class MappableFieldsQuerySerializer(serializers.Serializer):
219
+ """Query parameters of ``GET /fields/`` (OpenAPI documentation only)."""
220
+
221
+ connection = serializers.IntegerField(help_text='ID of an enabled InvenTree instance')
222
+ content_type = serializers.IntegerField(help_text='ID of the NetBox content type to map')
223
+ endpoint = serializers.CharField(help_text='InvenTree endpoint path, e.g. /api/part/')
224
+
225
+
226
+ class NetboxFieldSpecSerializer(serializers.Serializer):
227
+ """One mappable NetBox field, as returned by ``GET /fields/``."""
228
+
229
+ name = serializers.CharField()
230
+ label = serializers.CharField()
231
+ type = serializers.ChoiceField(choices=('text', 'boolean', 'integer', 'float', 'date', 'pointer'))
232
+ pointer_model = serializers.CharField(allow_null=True)
233
+ custom = serializers.BooleanField()
234
+ subfield_options = serializers.ListField(child=serializers.CharField())
235
+
236
+
237
+ class InventreeFieldSpecSerializer(serializers.Serializer):
238
+ """One writable InvenTree field plus its compatible NetBox field names."""
239
+
240
+ name = serializers.CharField()
241
+ type = serializers.ChoiceField(choices=('string', 'boolean', 'integer', 'float', 'related'))
242
+ required = serializers.BooleanField()
243
+ max_length = serializers.IntegerField(allow_null=True)
244
+ related_model = serializers.CharField(allow_null=True)
245
+ compatible_netbox_fields = serializers.ListField(child=serializers.CharField())
246
+
247
+
248
+ class MappableFieldsResponseSerializer(serializers.Serializer):
249
+ """Response body of ``GET /fields/`` — the mapping-builder matrix."""
250
+
251
+ netbox = NetboxFieldSpecSerializer(many=True, read_only=True)
252
+ inventree = InventreeFieldSpecSerializer(many=True, read_only=True)
253
+ available_pairings = serializers.ListField(child=serializers.CharField(), read_only=True)
@@ -0,0 +1,25 @@
1
+ """Router registration for the plugin's REST API.
2
+
3
+ NetBox auto-discovers this module (``netbox/plugins/urls.py`` imports
4
+ ``<plugin>.api.urls`` and mounts its ``urlpatterns`` under
5
+ ``/api/plugins/netbox_inventree_sync/``), so nothing needs to reference
6
+ it from the plugin's UI ``urls.py``.
7
+ """
8
+
9
+ from django.urls import path
10
+ from netbox.api.routers import NetBoxRouter
11
+
12
+ from . import views
13
+
14
+ app_name = 'netbox_inventree_sync-api'
15
+
16
+ router = NetBoxRouter()
17
+ router.register('connections', views.InvenTreeInstanceViewSet)
18
+ router.register('mappings', views.SyncMappingViewSet)
19
+ router.register('synced-objects', views.SyncedObjectViewSet)
20
+ router.register('sync-log', views.SyncLogEntryViewSet)
21
+
22
+ urlpatterns = [
23
+ path('fields/', views.MappableFieldsView.as_view(), name='fields'),
24
+ *router.urls,
25
+ ]
@@ -0,0 +1,185 @@
1
+ """API views for the InvenTree sync plugin.
2
+
3
+ Model viewsets follow NetBox's plugin conventions
4
+ (``NetBoxModelViewSet`` with default ``TokenPermissions``: authenticated
5
+ users plus per-model Django permissions). Two bespoke endpoints:
6
+
7
+ * ``GET /fields/`` — the mapping-builder matrix: every mappable NetBox
8
+ field of a content type, every writable InvenTree field of an endpoint
9
+ (each annotated with the NetBox fields the compatibility matrix
10
+ accepts), and the content types available as pointer pairings.
11
+ * ``POST /mappings/<id>/sync/`` — "Sync now": enqueue a one-shot
12
+ ``ReconcileJob`` for a mapping (in addition to any recurring
13
+ schedule), mirroring NetBox's own DataSource sync action.
14
+ """
15
+
16
+ from dataclasses import asdict
17
+
18
+ from django.contrib.contenttypes.models import ContentType
19
+ from django.shortcuts import get_object_or_404
20
+ from drf_spectacular.utils import extend_schema
21
+ from rest_framework import status
22
+ from rest_framework.decorators import action
23
+ from rest_framework.exceptions import ValidationError
24
+ from rest_framework.permissions import IsAuthenticated
25
+ from rest_framework.response import Response
26
+ from rest_framework.reverse import reverse
27
+ from rest_framework.views import APIView
28
+
29
+ from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
30
+
31
+ from netbox_inventree_sync.api.exceptions import InvenTreeUnavailable
32
+ from netbox_inventree_sync.filtersets import (
33
+ InvenTreeInstanceFilterSet,
34
+ SyncLogEntryFilterSet,
35
+ SyncMappingFilterSet,
36
+ SyncedObjectFilterSet,
37
+ )
38
+ from netbox_inventree_sync.jobs import ReconcileJob
39
+ from netbox_inventree_sync.models import InvenTreeInstance, SyncLogEntry, SyncMapping, SyncedObject
40
+ from netbox_inventree_sync.sync.client import InvenTreeClient, InvenTreeClientError
41
+ from netbox_inventree_sync.sync.compatibility import compatible
42
+ from netbox_inventree_sync.sync.fields import get_netbox_fields
43
+
44
+ from . import serializers
45
+
46
+ __all__ = (
47
+ 'InvenTreeInstanceViewSet',
48
+ 'InvenTreeUnavailable',
49
+ 'MappableFieldsView',
50
+ 'SyncLogEntryViewSet',
51
+ 'SyncMappingViewSet',
52
+ 'SyncedObjectViewSet',
53
+ )
54
+
55
+
56
+ class InvenTreeInstanceViewSet(NetBoxModelViewSet):
57
+ queryset = InvenTreeInstance.objects.all()
58
+ serializer_class = serializers.InvenTreeInstanceSerializer
59
+ filterset_class = InvenTreeInstanceFilterSet
60
+
61
+
62
+ class SyncMappingViewSet(NetBoxModelViewSet):
63
+ queryset = SyncMapping.objects.all()
64
+ serializer_class = serializers.SyncMappingSerializer
65
+ filterset_class = SyncMappingFilterSet
66
+
67
+ @extend_schema(request=None)
68
+ @action(detail=True, methods=['post'])
69
+ def sync(self, request, pk):
70
+ """Enqueue a one-shot reconcile job for this mapping ("Sync now").
71
+
72
+ Unlike the recurring schedule maintained by the model, this job
73
+ carries no interval and runs at the first available opportunity.
74
+ """
75
+ mapping = get_object_or_404(self.get_queryset(), pk=pk)
76
+ job = ReconcileJob.enqueue(instance=mapping, mapping_id=mapping.pk, user=request.user)
77
+ return Response({
78
+ 'job': {
79
+ 'id': job.pk,
80
+ 'url': reverse('core-api:job-detail', kwargs={'pk': job.pk}, request=request),
81
+ 'status': job.status,
82
+ },
83
+ }, status=status.HTTP_202_ACCEPTED)
84
+
85
+
86
+ class SyncedObjectViewSet(NetBoxReadOnlyModelViewSet):
87
+ queryset = SyncedObject.objects.all()
88
+ serializer_class = serializers.SyncedObjectSerializer
89
+ filterset_class = SyncedObjectFilterSet
90
+
91
+
92
+ class SyncLogEntryViewSet(NetBoxReadOnlyModelViewSet):
93
+ queryset = SyncLogEntry.objects.all()
94
+ serializer_class = serializers.SyncLogEntrySerializer
95
+ filterset_class = SyncLogEntryFilterSet
96
+
97
+
98
+ class MappableFieldsView(APIView):
99
+ """Compatibility matrix between one NetBox content type and one InvenTree endpoint.
100
+
101
+ ``GET fields/?connection=<id>&content_type=<ct_id>&endpoint=<path>``
102
+ returns the NetBox field specs, the InvenTree field specs (each with
103
+ the ``compatible_netbox_fields`` names the Task 6 matrix accepts for
104
+ it), and the connection's available pairing keys (the
105
+ ``'<app>.<model>'`` content types of its enabled mappings).
106
+
107
+ NetBox's default ``TokenPermissions`` derives permission codes from a
108
+ viewset's queryset/model; this view deliberately spans two plugin
109
+ models, so it declares plain authentication-based permissions.
110
+ """
111
+
112
+ permission_classes = [IsAuthenticated]
113
+
114
+ @extend_schema(
115
+ parameters=[serializers.MappableFieldsQuerySerializer],
116
+ responses={200: serializers.MappableFieldsResponseSerializer},
117
+ )
118
+ def get(self, request):
119
+ connection = self._get_connection(request)
120
+ content_type = self._get_content_type(request)
121
+ endpoint = (request.query_params.get('endpoint') or '').strip()
122
+ if not endpoint:
123
+ raise ValidationError({'endpoint': 'This query parameter is required (e.g. /api/part/).'})
124
+
125
+ netbox_specs = get_netbox_fields(content_type)
126
+ available_pairings = self._available_pairings(connection)
127
+ try:
128
+ client = InvenTreeClient(connection.url, connection.api_token)
129
+ inventree_specs = client.get_field_specs(endpoint)
130
+ except InvenTreeClientError as exc:
131
+ # 502-style: the failure is upstream, not in the request.
132
+ raise InvenTreeUnavailable(str(exc))
133
+
134
+ return Response({
135
+ 'netbox': [asdict(spec) for spec in netbox_specs],
136
+ 'inventree': [
137
+ {
138
+ **asdict(spec),
139
+ 'compatible_netbox_fields': [
140
+ netbox_spec.name for netbox_spec in netbox_specs
141
+ if compatible(netbox_spec, spec, available_pairings)
142
+ ],
143
+ }
144
+ for spec in inventree_specs
145
+ ],
146
+ 'available_pairings': sorted(available_pairings),
147
+ })
148
+
149
+ @staticmethod
150
+ def _get_connection(request):
151
+ raw = request.query_params.get('connection')
152
+ if raw is None:
153
+ raise ValidationError({'connection': 'This query parameter is required.'})
154
+ # Restrict to connections the user may view, mirroring the viewsets'
155
+ # object-level restriction; a forbidden connection is then
156
+ # indistinguishable from a nonexistent one.
157
+ queryset = InvenTreeInstance.objects.restrict(request.user, 'view')
158
+ try:
159
+ connection = queryset.get(pk=raw)
160
+ except (InvenTreeInstance.DoesNotExist, ValueError, TypeError):
161
+ raise ValidationError({'connection': f'No InvenTree instance with ID {raw!r} exists.'})
162
+ if not connection.enabled:
163
+ raise ValidationError({'connection': f'InvenTree instance {connection.name!r} is disabled.'})
164
+ return connection
165
+
166
+ @staticmethod
167
+ def _get_content_type(request):
168
+ raw = request.query_params.get('content_type')
169
+ if raw is None:
170
+ raise ValidationError({'content_type': 'This query parameter is required.'})
171
+ try:
172
+ return ContentType.objects.get_for_id(raw)
173
+ except (ContentType.DoesNotExist, ValueError, TypeError):
174
+ raise ValidationError({'content_type': f'No content type with ID {raw!r} exists.'})
175
+
176
+ @staticmethod
177
+ def _available_pairings(connection) -> set:
178
+ """``'<app>.<model>'`` keys of the connection's enabled mappings."""
179
+ return set(
180
+ f'{app_label}.{model}'
181
+ for app_label, model in SyncMapping.objects.filter(
182
+ connection=connection,
183
+ enabled=True,
184
+ ).values_list('netbox_content_type__app_label', 'netbox_content_type__model')
185
+ )
@@ -0,0 +1,28 @@
1
+ from django.db.models import TextChoices
2
+
3
+
4
+ class SyncStatusChoices(TextChoices):
5
+ """Link-table states for a tracked NetBox object."""
6
+
7
+ PENDING = 'pending', 'Pending'
8
+ SYNCED = 'synced', 'Synced'
9
+ FAILED = 'failed', 'Failed'
10
+ ORPHANED = 'orphaned', 'Orphaned'
11
+
12
+
13
+ class SyncActionChoices(TextChoices):
14
+ """Actions recorded in the sync log."""
15
+
16
+ CREATE = 'create', 'Create'
17
+ UPDATE = 'update', 'Update'
18
+ DEACTIVATE = 'deactivate', 'Deactivate'
19
+ ADOPT = 'adopt', 'Adopt'
20
+ ERROR = 'error', 'Error'
21
+
22
+
23
+ class TransformChoices(TextChoices):
24
+ """Value transforms applied when pushing a NetBox field to InvenTree."""
25
+
26
+ NONE = 'none', 'None'
27
+ ISO_DATE = 'iso_date', 'ISO date'
28
+ STR = 'str', 'String'
@@ -0,0 +1,104 @@
1
+ """FilterSets for the plugin's list views."""
2
+
3
+ import django_filters
4
+ from django.db.models import Q
5
+ from django.utils.translation import gettext_lazy as _
6
+
7
+ from netbox.filtersets import NetBoxModelFilterSet
8
+
9
+ from .choices import SyncActionChoices, SyncStatusChoices
10
+ from .models import InvenTreeInstance, SyncLogEntry, SyncMapping, SyncedObject
11
+
12
+ __all__ = (
13
+ 'InvenTreeInstanceFilterSet',
14
+ 'SyncLogEntryFilterSet',
15
+ 'SyncMappingFilterSet',
16
+ 'SyncedObjectFilterSet',
17
+ )
18
+
19
+
20
+ class InvenTreeInstanceFilterSet(NetBoxModelFilterSet):
21
+
22
+ class Meta:
23
+ model = InvenTreeInstance
24
+ fields = ('id', 'name', 'url', 'enabled')
25
+
26
+ def search(self, queryset, name, value):
27
+ if not value.strip():
28
+ return queryset
29
+ return queryset.filter(
30
+ Q(name__icontains=value) |
31
+ Q(url__icontains=value)
32
+ )
33
+
34
+
35
+ class SyncMappingFilterSet(NetBoxModelFilterSet):
36
+ connection_id = django_filters.ModelMultipleChoiceFilter(
37
+ field_name='connection',
38
+ queryset=InvenTreeInstance.objects.all(),
39
+ label=_('Connection (ID)'),
40
+ )
41
+
42
+ class Meta:
43
+ model = SyncMapping
44
+ fields = ('id', 'name', 'inventree_endpoint', 'enabled', 'reconcile_interval_minutes')
45
+
46
+ def search(self, queryset, name, value):
47
+ if not value.strip():
48
+ return queryset
49
+ return queryset.filter(
50
+ Q(name__icontains=value) |
51
+ Q(description__icontains=value) |
52
+ Q(inventree_endpoint__icontains=value)
53
+ )
54
+
55
+
56
+ class SyncedObjectFilterSet(NetBoxModelFilterSet):
57
+
58
+ status = django_filters.MultipleChoiceFilter(
59
+ choices=SyncStatusChoices,
60
+ distinct=False,
61
+ )
62
+ mapping_id = django_filters.ModelMultipleChoiceFilter(
63
+ field_name='mapping',
64
+ queryset=SyncMapping.objects.all(),
65
+ label=_('Mapping (ID)'),
66
+ )
67
+
68
+ class Meta:
69
+ model = SyncedObject
70
+ fields = ('id', 'status', 'mapping_id')
71
+
72
+ def search(self, queryset, name, value):
73
+ if not value.strip():
74
+ return queryset
75
+ return queryset.filter(
76
+ Q(mapping__name__icontains=value) |
77
+ Q(mapping__inventree_endpoint__icontains=value) |
78
+ Q(last_error__icontains=value)
79
+ )
80
+
81
+
82
+ class SyncLogEntryFilterSet(NetBoxModelFilterSet):
83
+
84
+ action = django_filters.MultipleChoiceFilter(
85
+ choices=SyncActionChoices,
86
+ distinct=False,
87
+ )
88
+ synced_id = django_filters.ModelMultipleChoiceFilter(
89
+ field_name='synced',
90
+ queryset=SyncedObject.objects.all(),
91
+ label=_('Synced object (ID)'),
92
+ )
93
+
94
+ class Meta:
95
+ model = SyncLogEntry
96
+ fields = ('id', 'action', 'synced_id')
97
+
98
+ def search(self, queryset, name, value):
99
+ if not value.strip():
100
+ return queryset
101
+ return queryset.filter(
102
+ Q(synced__mapping__name__icontains=value) |
103
+ Q(response_body__icontains=value)
104
+ )