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.
- netbox_inventree_sync/__init__.py +42 -0
- netbox_inventree_sync/api/__init__.py +8 -0
- netbox_inventree_sync/api/exceptions.py +19 -0
- netbox_inventree_sync/api/serializers.py +253 -0
- netbox_inventree_sync/api/urls.py +25 -0
- netbox_inventree_sync/api/views.py +185 -0
- netbox_inventree_sync/choices.py +28 -0
- netbox_inventree_sync/filtersets.py +104 -0
- netbox_inventree_sync/forms.py +194 -0
- netbox_inventree_sync/graphql/__init__.py +18 -0
- netbox_inventree_sync/graphql/filters.py +31 -0
- netbox_inventree_sync/graphql/schema.py +19 -0
- netbox_inventree_sync/graphql/types.py +51 -0
- netbox_inventree_sync/jobs.py +114 -0
- netbox_inventree_sync/management/__init__.py +0 -0
- netbox_inventree_sync/management/commands/__init__.py +0 -0
- netbox_inventree_sync/management/commands/inventree_sync.py +167 -0
- netbox_inventree_sync/management/commands/seed_starter_mappings.py +198 -0
- netbox_inventree_sync/migrations/0001_initial.py +140 -0
- netbox_inventree_sync/migrations/0002_field_validators_and_help_text.py +17 -0
- netbox_inventree_sync/migrations/__init__.py +0 -0
- netbox_inventree_sync/models.py +310 -0
- netbox_inventree_sync/navigation.py +51 -0
- netbox_inventree_sync/search.py +37 -0
- netbox_inventree_sync/signals.py +258 -0
- netbox_inventree_sync/sync/__init__.py +13 -0
- netbox_inventree_sync/sync/client.py +237 -0
- netbox_inventree_sync/sync/compatibility.py +60 -0
- netbox_inventree_sync/sync/engine.py +671 -0
- netbox_inventree_sync/sync/fields.py +203 -0
- netbox_inventree_sync/sync/payload.py +47 -0
- netbox_inventree_sync/sync/validation.py +295 -0
- netbox_inventree_sync/tables.py +322 -0
- netbox_inventree_sync/template_content.py +102 -0
- netbox_inventree_sync/tests/__init__.py +0 -0
- netbox_inventree_sync/tests/test_api.py +589 -0
- netbox_inventree_sync/tests/test_builder_ui.py +264 -0
- netbox_inventree_sync/tests/test_client.py +377 -0
- netbox_inventree_sync/tests/test_compatibility.py +158 -0
- netbox_inventree_sync/tests/test_engine.py +452 -0
- netbox_inventree_sync/tests/test_extras.py +285 -0
- netbox_inventree_sync/tests/test_fields.py +106 -0
- netbox_inventree_sync/tests/test_jobs.py +470 -0
- netbox_inventree_sync/tests/test_models.py +241 -0
- netbox_inventree_sync/tests/test_pointer_resolution.py +624 -0
- netbox_inventree_sync/tests/test_ui.py +531 -0
- netbox_inventree_sync/tests/test_validation.py +382 -0
- netbox_inventree_sync/urls.py +33 -0
- netbox_inventree_sync/views.py +630 -0
- netbox_inventree_sync-0.1.0.dist-info/METADATA +356 -0
- netbox_inventree_sync-0.1.0.dist-info/RECORD +54 -0
- netbox_inventree_sync-0.1.0.dist-info/WHEEL +5 -0
- netbox_inventree_sync-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
)
|