Просмотр исходного кода

Closes #11558: Add support for remote data sources (#11646)

* WIP

* WIP

* Add git sync

* Fix file hashing

* Add last_synced to DataSource

* Build out UI & API resources

* Add status field to DataSource

* Add UI control to sync data source

* Add API endpoint to sync data sources

* Fix display of DataSource job results

* DataSource password should be write-only

* General cleanup

* Add data file UI view

* Punt on HTTP, FTP support for now

* Add DataSource URL validation

* Add HTTP proxy support to git fetcher

* Add management command to sync data sources

* DataFile REST API endpoints should be read-only

* Refactor fetch methods into backend classes

* Replace auth & git branch fields with general-purpose parameters

* Fix last_synced time

* Render discrete form fields for backend parameters

* Enable dynamic edit form for DataSource

* Register DataBackend classes in application registry

* Add search indexers for DataSource, DataFile

* Add single & bulk delete views for DataFile

* Add model documentation

* Convert DataSource to a primary model

* Introduce pre_sync & post_sync signals

* Clean up migrations

* Rename url to source_url

* Clean up filtersets

* Add API & filterset tests

* Add view tests

* Add initSelect() to HTMX refresh handler

* Render DataSourceForm fieldsets dynamically

* Update compiled static resources
Jeremy Stretch 3 лет назад
Родитель
Сommit
d8784d4155
53 измененных файлов с 1857 добавлено и 6 удалено
  1. 25 0
      docs/models/core/datafile.md
  2. 47 0
      docs/models/core/datasource.md
  3. 0 0
      netbox/core/__init__.py
  4. 0 0
      netbox/core/api/__init__.py
  5. 25 0
      netbox/core/api/nested_serializers.py
  6. 51 0
      netbox/core/api/serializers.py
  7. 13 0
      netbox/core/api/urls.py
  8. 52 0
      netbox/core/api/views.py
  9. 8 0
      netbox/core/apps.py
  10. 34 0
      netbox/core/choices.py
  11. 117 0
      netbox/core/data_backends.py
  12. 2 0
      netbox/core/exceptions.py
  13. 64 0
      netbox/core/filtersets.py
  14. 4 0
      netbox/core/forms/__init__.py
  15. 50 0
      netbox/core/forms/bulk_edit.py
  16. 15 0
      netbox/core/forms/bulk_import.py
  17. 49 0
      netbox/core/forms/filtersets.py
  18. 81 0
      netbox/core/forms/model_forms.py
  19. 0 0
      netbox/core/graphql/__init__.py
  20. 12 0
      netbox/core/graphql/schema.py
  21. 21 0
      netbox/core/graphql/types.py
  22. 29 0
      netbox/core/jobs.py
  23. 0 0
      netbox/core/management/__init__.py
  24. 0 0
      netbox/core/management/commands/__init__.py
  25. 41 0
      netbox/core/management/commands/syncdatasource.py
  26. 62 0
      netbox/core/migrations/0001_initial.py
  27. 0 0
      netbox/core/migrations/__init__.py
  28. 1 0
      netbox/core/models/__init__.py
  29. 302 0
      netbox/core/models/data.py
  30. 21 0
      netbox/core/search.py
  31. 10 0
      netbox/core/signals.py
  32. 1 0
      netbox/core/tables/__init__.py
  33. 52 0
      netbox/core/tables/data.py
  34. 0 0
      netbox/core/tests/__init__.py
  35. 93 0
      netbox/core/tests/test_api.py
  36. 120 0
      netbox/core/tests/test_filtersets.py
  37. 91 0
      netbox/core/tests/test_views.py
  38. 22 0
      netbox/core/urls.py
  39. 118 0
      netbox/core/views.py
  40. 1 1
      netbox/extras/management/commands/nbshell.py
  41. 7 3
      netbox/extras/models/models.py
  42. 1 0
      netbox/netbox/api/views.py
  43. 2 0
      netbox/netbox/graphql/schema.py
  44. 1 0
      netbox/netbox/navigation/menu.py
  45. 2 1
      netbox/netbox/registry.py
  46. 1 0
      netbox/netbox/settings.py
  47. 2 0
      netbox/netbox/urls.py
  48. 0 0
      netbox/project-static/dist/netbox.js
  49. 0 0
      netbox/project-static/dist/netbox.js.map
  50. 3 1
      netbox/project-static/src/htmx.ts
  51. 81 0
      netbox/templates/core/datafile.html
  52. 114 0
      netbox/templates/core/datasource.html
  53. 9 0
      netbox/utilities/files.py

+ 25 - 0
docs/models/core/datafile.md

@@ -0,0 +1,25 @@
+# Data Files
+
+A data file object is the representation in NetBox's database of some file belonging to a remote [data source](./datasource.md). Data files are synchronized automatically, and cannot be modified locally (although they can be deleted).
+
+## Fields
+
+### Source
+
+The [data source](./datasource.md) to which this file belongs.
+
+### Path
+
+The path to the file, relative to its source's URL. For example, a file at `/opt/config-data/routing/bgp/peer.yaml` with a source URL of `file:///opt/config-data/` would have its path set to `routing/bgp/peer.yaml`.
+
+### Last Updated
+
+The date and time at which the file most recently updated from its source. Note that this attribute is updated only when the file's contents have been modified. Re-synchronizing the data source will not update this timestamp if the upstream file's data has not changed.
+
+### Size
+
+The file's size, in bytes.
+
+### Hash
+
+A [SHA256 hash](https://en.wikipedia.org/wiki/SHA-2) of the file's data. This can be compared to a hash taken from the original file to determine whether any changes have been made.

+ 47 - 0
docs/models/core/datasource.md

@@ -0,0 +1,47 @@
+# Data Sources
+
+A data source represents some external repository of data which NetBox can consume, such as a git repository. Files within the data source are synchronized to NetBox by saving them in the database as [data file](./datafile.md) objects.
+
+## Fields
+
+### Name
+
+The data source's human-friendly name.
+
+### Type
+
+The type of data source. Supported options include:
+
+* Local directory
+* git repository
+
+### URL
+
+The URL identifying the remote source. Some examples are included below.
+
+| Type | Example URL |
+|------|-------------|
+| Local | file:///var/my/data/source/ |
+| git | https://https://github.com/my-organization/my-repo |
+
+### Status
+
+The source's current synchronization status. Note that this cannot be set manually: It is updated automatically when the source is synchronized.
+
+### Enabled
+
+If false, synchronization will be disabled.
+
+### Ignore Rules
+
+A set of rules (one per line) identifying filenames to ignore during synchronization. Some examples are provided below. See Python's [`fnmatch()` documentation](https://docs.python.org/3/library/fnmatch.html) for a complete reference.
+
+| Rule           | Description                              |
+|----------------|------------------------------------------|
+| `README`       | Ignore any files named `README`          |
+| `*.txt`        | Ignore any files with a `.txt` extension |
+| `data???.json` | Ignore e.g. `data123.json`               |
+
+### Last Synced
+
+The date and time at which the source was most recently synchronized successfully.

+ 0 - 0
netbox/core/__init__.py


+ 0 - 0
netbox/core/api/__init__.py


+ 25 - 0
netbox/core/api/nested_serializers.py

@@ -0,0 +1,25 @@
+from rest_framework import serializers
+
+from core.models import *
+from netbox.api.serializers import WritableNestedSerializer
+
+__all__ = [
+    'NestedDataFileSerializer',
+    'NestedDataSourceSerializer',
+]
+
+
+class NestedDataSourceSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='core-api:datasource-detail')
+
+    class Meta:
+        model = DataSource
+        fields = ['id', 'url', 'display', 'name']
+
+
+class NestedDataFileSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='core-api:datafile-detail')
+
+    class Meta:
+        model = DataFile
+        fields = ['id', 'url', 'display', 'path']

+ 51 - 0
netbox/core/api/serializers.py

@@ -0,0 +1,51 @@
+from rest_framework import serializers
+
+from core.choices import *
+from core.models import *
+from netbox.api.fields import ChoiceField
+from netbox.api.serializers import NetBoxModelSerializer
+from .nested_serializers import *
+
+__all__ = (
+    'DataSourceSerializer',
+)
+
+
+class DataSourceSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(
+        view_name='core-api:datasource-detail'
+    )
+    type = ChoiceField(
+        choices=DataSourceTypeChoices
+    )
+    status = ChoiceField(
+        choices=DataSourceStatusChoices,
+        read_only=True
+    )
+
+    # Related object counts
+    file_count = serializers.IntegerField(
+        read_only=True
+    )
+
+    class Meta:
+        model = DataSource
+        fields = [
+            'id', 'url', 'display', 'name', 'type', 'source_url', 'enabled', 'status', 'description', 'comments',
+            'parameters', 'ignore_rules', 'created', 'last_updated', 'file_count',
+        ]
+
+
+class DataFileSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(
+        view_name='core-api:datafile-detail'
+    )
+    source = NestedDataSourceSerializer(
+        read_only=True
+    )
+
+    class Meta:
+        model = DataFile
+        fields = [
+            'id', 'url', 'display', 'source', 'path', 'last_updated', 'size', 'hash',
+        ]

+ 13 - 0
netbox/core/api/urls.py

@@ -0,0 +1,13 @@
+from netbox.api.routers import NetBoxRouter
+from . import views
+
+
+router = NetBoxRouter()
+router.APIRootView = views.CoreRootView
+
+# Data sources
+router.register('data-sources', views.DataSourceViewSet)
+router.register('data-files', views.DataFileViewSet)
+
+app_name = 'core-api'
+urlpatterns = router.urls

+ 52 - 0
netbox/core/api/views.py

@@ -0,0 +1,52 @@
+from django.shortcuts import get_object_or_404
+
+from rest_framework.decorators import action
+from rest_framework.exceptions import PermissionDenied
+from rest_framework.response import Response
+from rest_framework.routers import APIRootView
+
+from core import filtersets
+from core.models import *
+from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
+from utilities.utils import count_related
+from . import serializers
+
+
+class CoreRootView(APIRootView):
+    """
+    Core API root view
+    """
+    def get_view_name(self):
+        return 'Core'
+
+
+#
+# Data sources
+#
+
+class DataSourceViewSet(NetBoxModelViewSet):
+    queryset = DataSource.objects.annotate(
+        file_count=count_related(DataFile, 'source')
+    )
+    serializer_class = serializers.DataSourceSerializer
+    filterset_class = filtersets.DataSourceFilterSet
+
+    @action(detail=True, methods=['post'])
+    def sync(self, request, pk):
+        """
+        Enqueue a job to synchronize the DataSource.
+        """
+        if not request.user.has_perm('extras.sync_datasource'):
+            raise PermissionDenied("Syncing data sources requires the core.sync_datasource permission.")
+
+        datasource = get_object_or_404(DataSource, pk=pk)
+        datasource.enqueue_sync_job(request)
+        serializer = serializers.DataSourceSerializer(datasource, context={'request': request})
+
+        return Response(serializer.data)
+
+
+class DataFileViewSet(NetBoxReadOnlyModelViewSet):
+    queryset = DataFile.objects.defer('data').prefetch_related('source')
+    serializer_class = serializers.DataFileSerializer
+    filterset_class = filtersets.DataFileFilterSet

+ 8 - 0
netbox/core/apps.py

@@ -0,0 +1,8 @@
+from django.apps import AppConfig
+
+
+class CoreConfig(AppConfig):
+    name = "core"
+
+    def ready(self):
+        from . import data_backends, search

+ 34 - 0
netbox/core/choices.py

@@ -0,0 +1,34 @@
+from django.utils.translation import gettext as _
+
+from utilities.choices import ChoiceSet
+
+
+#
+# Data sources
+#
+
+class DataSourceTypeChoices(ChoiceSet):
+    LOCAL = 'local'
+    GIT = 'git'
+
+    CHOICES = (
+        (LOCAL, _('Local'), 'gray'),
+        (GIT, _('Git'), 'blue'),
+    )
+
+
+class DataSourceStatusChoices(ChoiceSet):
+
+    NEW = 'new'
+    QUEUED = 'queued'
+    SYNCING = 'syncing'
+    COMPLETED = 'completed'
+    FAILED = 'failed'
+
+    CHOICES = (
+        (NEW, _('New'), 'blue'),
+        (QUEUED, _('Queued'), 'orange'),
+        (SYNCING, _('Syncing'), 'cyan'),
+        (COMPLETED, _('Completed'), 'green'),
+        (FAILED, _('Failed'), 'red'),
+    )

+ 117 - 0
netbox/core/data_backends.py

@@ -0,0 +1,117 @@
+import logging
+import subprocess
+import tempfile
+from contextlib import contextmanager
+from urllib.parse import quote, urlunparse, urlparse
+
+from django import forms
+from django.conf import settings
+from django.utils.translation import gettext as _
+
+from netbox.registry import registry
+from .choices import DataSourceTypeChoices
+from .exceptions import SyncError
+
+__all__ = (
+    'LocalBackend',
+    'GitBackend',
+)
+
+logger = logging.getLogger('netbox.data_backends')
+
+
+def register_backend(name):
+    """
+    Decorator for registering a DataBackend class.
+    """
+    def _wrapper(cls):
+        registry['data_backends'][name] = cls
+        return cls
+
+    return _wrapper
+
+
+class DataBackend:
+    parameters = {}
+
+    def __init__(self, url, **kwargs):
+        self.url = url
+        self.params = kwargs
+
+    @property
+    def url_scheme(self):
+        return urlparse(self.url).scheme.lower()
+
+    @contextmanager
+    def fetch(self):
+        raise NotImplemented()
+
+
+@register_backend(DataSourceTypeChoices.LOCAL)
+class LocalBackend(DataBackend):
+
+    @contextmanager
+    def fetch(self):
+        logger.debug(f"Data source type is local; skipping fetch")
+        local_path = urlparse(self.url).path  # Strip file:// scheme
+
+        yield local_path
+
+
+@register_backend(DataSourceTypeChoices.GIT)
+class GitBackend(DataBackend):
+    parameters = {
+        'username': forms.CharField(
+            required=False,
+            label=_('Username'),
+            widget=forms.TextInput(attrs={'class': 'form-control'})
+        ),
+        'password': forms.CharField(
+            required=False,
+            label=_('Password'),
+            widget=forms.TextInput(attrs={'class': 'form-control'})
+        ),
+        'branch': forms.CharField(
+            required=False,
+            label=_('Branch'),
+            widget=forms.TextInput(attrs={'class': 'form-control'})
+        )
+    }
+
+    @contextmanager
+    def fetch(self):
+        local_path = tempfile.TemporaryDirectory()
+
+        # Add authentication credentials to URL (if specified)
+        username = self.params.get('username')
+        password = self.params.get('password')
+        if username and password:
+            url_components = list(urlparse(self.url))
+            # Prepend username & password to netloc
+            url_components[1] = quote(f'{username}@{password}:') + url_components[1]
+            url = urlunparse(url_components)
+        else:
+            url = self.url
+
+        # Compile git arguments
+        args = ['git', 'clone', '--depth', '1']
+        if branch := self.params.get('branch'):
+            args.extend(['--branch', branch])
+        args.extend([url, local_path.name])
+
+        # Prep environment variables
+        env_vars = {}
+        if settings.HTTP_PROXIES and self.url_scheme in ('http', 'https'):
+            env_vars['http_proxy'] = settings.HTTP_PROXIES.get(self.url_scheme)
+
+        logger.debug(f"Cloning git repo: {' '.join(args)}")
+        try:
+            subprocess.run(args, check=True, capture_output=True, env=env_vars)
+        except subprocess.CalledProcessError as e:
+            raise SyncError(
+                f"Fetching remote data failed: {e.stderr}"
+            )
+
+        yield local_path.name
+
+        local_path.cleanup()

+ 2 - 0
netbox/core/exceptions.py

@@ -0,0 +1,2 @@
+class SyncError(Exception):
+    pass

+ 64 - 0
netbox/core/filtersets.py

@@ -0,0 +1,64 @@
+from django.db.models import Q
+from django.utils.translation import gettext as _
+
+import django_filters
+
+from netbox.filtersets import ChangeLoggedModelFilterSet, NetBoxModelFilterSet
+from .choices import *
+from .models import *
+
+__all__ = (
+    'DataFileFilterSet',
+    'DataSourceFilterSet',
+)
+
+
+class DataSourceFilterSet(NetBoxModelFilterSet):
+    type = django_filters.MultipleChoiceFilter(
+        choices=DataSourceTypeChoices,
+        null_value=None
+    )
+    status = django_filters.MultipleChoiceFilter(
+        choices=DataSourceStatusChoices,
+        null_value=None
+    )
+
+    class Meta:
+        model = DataSource
+        fields = ('id', 'name', 'enabled')
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(name__icontains=value) |
+            Q(description__icontains=value) |
+            Q(comments__icontains=value)
+        )
+
+
+class DataFileFilterSet(ChangeLoggedModelFilterSet):
+    q = django_filters.CharFilter(
+        method='search'
+    )
+    source_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=DataSource.objects.all(),
+        label=_('Data source (ID)'),
+    )
+    source = django_filters.ModelMultipleChoiceFilter(
+        field_name='source__name',
+        queryset=DataSource.objects.all(),
+        to_field_name='name',
+        label=_('Data source (name)'),
+    )
+
+    class Meta:
+        model = DataFile
+        fields = ('id', 'path', 'last_updated', 'size', 'hash')
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(path__icontains=value)
+        )

+ 4 - 0
netbox/core/forms/__init__.py

@@ -0,0 +1,4 @@
+from .bulk_edit import *
+from .bulk_import import *
+from .filtersets import *
+from .model_forms import *

+ 50 - 0
netbox/core/forms/bulk_edit.py

@@ -0,0 +1,50 @@
+from django import forms
+from django.utils.translation import gettext as _
+
+from core.choices import DataSourceTypeChoices
+from core.models import *
+from netbox.forms import NetBoxModelBulkEditForm
+from utilities.forms import (
+    add_blank_choice, BulkEditNullBooleanSelect, CommentField, SmallTextarea, StaticSelect,
+)
+
+__all__ = (
+    'DataSourceBulkEditForm',
+)
+
+
+class DataSourceBulkEditForm(NetBoxModelBulkEditForm):
+    type = forms.ChoiceField(
+        choices=add_blank_choice(DataSourceTypeChoices),
+        required=False,
+        initial='',
+        widget=StaticSelect()
+    )
+    enabled = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect(),
+        label=_('Enforce unique space')
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+    comments = CommentField(
+        widget=SmallTextarea,
+        label=_('Comments')
+    )
+    parameters = forms.JSONField(
+        required=False
+    )
+    ignore_rules = forms.CharField(
+        required=False,
+        widget=forms.Textarea()
+    )
+
+    model = DataSource
+    fieldsets = (
+        (None, ('type', 'enabled', 'description', 'comments', 'parameters', 'ignore_rules')),
+    )
+    nullable_fields = (
+        'description', 'description', 'parameters', 'comments', 'parameters', 'ignore_rules',
+    )

+ 15 - 0
netbox/core/forms/bulk_import.py

@@ -0,0 +1,15 @@
+from core.models import *
+from netbox.forms import NetBoxModelImportForm
+
+__all__ = (
+    'DataSourceImportForm',
+)
+
+
+class DataSourceImportForm(NetBoxModelImportForm):
+
+    class Meta:
+        model = DataSource
+        fields = (
+            'name', 'type', 'source_url', 'enabled', 'description', 'comments', 'parameters', 'ignore_rules',
+        )

+ 49 - 0
netbox/core/forms/filtersets.py

@@ -0,0 +1,49 @@
+from django import forms
+from django.utils.translation import gettext as _
+
+from core.choices import *
+from core.models import *
+from netbox.forms import NetBoxModelFilterSetForm
+from utilities.forms import (
+    BOOLEAN_WITH_BLANK_CHOICES, DynamicModelMultipleChoiceField, MultipleChoiceField, StaticSelect,
+)
+
+__all__ = (
+    'DataFileFilterForm',
+    'DataSourceFilterForm',
+)
+
+
+class DataSourceFilterForm(NetBoxModelFilterSetForm):
+    model = DataSource
+    fieldsets = (
+        (None, ('q', 'filter_id')),
+        ('Data Source', ('type', 'status')),
+    )
+    type = MultipleChoiceField(
+        choices=DataSourceTypeChoices,
+        required=False
+    )
+    status = MultipleChoiceField(
+        choices=DataSourceStatusChoices,
+        required=False
+    )
+    enabled = forms.NullBooleanField(
+        required=False,
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+
+
+class DataFileFilterForm(NetBoxModelFilterSetForm):
+    model = DataFile
+    fieldsets = (
+        (None, ('q', 'filter_id')),
+        ('File', ('source_id',)),
+    )
+    source_id = DynamicModelMultipleChoiceField(
+        queryset=DataSource.objects.all(),
+        required=False,
+        label=_('Data source')
+    )

+ 81 - 0
netbox/core/forms/model_forms.py

@@ -0,0 +1,81 @@
+import copy
+
+from django import forms
+
+from core.models import *
+from netbox.forms import NetBoxModelForm, StaticSelect
+from netbox.registry import registry
+from utilities.forms import CommentField
+
+__all__ = (
+    'DataSourceForm',
+)
+
+
+class DataSourceForm(NetBoxModelForm):
+    comments = CommentField()
+
+    class Meta:
+        model = DataSource
+        fields = [
+            'name', 'type', 'source_url', 'enabled', 'description', 'comments', 'ignore_rules', 'tags',
+        ]
+        widgets = {
+            'type': StaticSelect(
+                attrs={
+                    'hx-get': '.',
+                    'hx-include': '#form_fields input',
+                    'hx-target': '#form_fields',
+                }
+            ),
+            'ignore_rules': forms.Textarea(
+                attrs={
+                    'rows': 5,
+                    'class': 'font-monospace',
+                    'placeholder': '.cache\n*.txt'
+                }
+            ),
+        }
+
+    @property
+    def fieldsets(self):
+        fieldsets = [
+            ('Source', ('name', 'type', 'source_url', 'enabled', 'description', 'tags', 'ignore_rules')),
+        ]
+        if self.backend_fields:
+            fieldsets.append(
+                ('Backend', self.backend_fields)
+            )
+
+        return fieldsets
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        backend_classes = registry['data_backends']
+
+        if self.is_bound and self.data.get('type') in backend_classes:
+            type_ = self.data['type']
+        elif self.initial and self.initial.get('type') in backend_classes:
+            type_ = self.initial['type']
+        else:
+            type_ = self.fields['type'].initial
+        backend = backend_classes.get(type_)
+
+        self.backend_fields = []
+        for name, form_field in backend.parameters.items():
+            field_name = f'backend_{name}'
+            self.backend_fields.append(field_name)
+            self.fields[field_name] = copy.copy(form_field)
+            if self.instance and self.instance.parameters:
+                self.fields[field_name].initial = self.instance.parameters.get(name)
+
+    def save(self, *args, **kwargs):
+
+        parameters = {}
+        for name in self.fields:
+            if name.startswith('backend_'):
+                parameters[name[8:]] = self.cleaned_data[name]
+        self.instance.parameters = parameters
+
+        return super().save(*args, **kwargs)

+ 0 - 0
netbox/core/graphql/__init__.py


+ 12 - 0
netbox/core/graphql/schema.py

@@ -0,0 +1,12 @@
+import graphene
+
+from netbox.graphql.fields import ObjectField, ObjectListField
+from .types import *
+
+
+class CoreQuery(graphene.ObjectType):
+    data_file = ObjectField(DataFileType)
+    data_file_list = ObjectListField(DataFileType)
+
+    data_source = ObjectField(DataSourceType)
+    data_source_list = ObjectListField(DataSourceType)

+ 21 - 0
netbox/core/graphql/types.py

@@ -0,0 +1,21 @@
+from core import filtersets, models
+from netbox.graphql.types import BaseObjectType, NetBoxObjectType
+
+__all__ = (
+    'DataFileType',
+    'DataSourceType',
+)
+
+
+class DataFileType(BaseObjectType):
+    class Meta:
+        model = models.DataFile
+        exclude = ('data',)
+        filterset_class = filtersets.DataFileFilterSet
+
+
+class DataSourceType(NetBoxObjectType):
+    class Meta:
+        model = models.DataSource
+        fields = '__all__'
+        filterset_class = filtersets.DataSourceFilterSet

+ 29 - 0
netbox/core/jobs.py

@@ -0,0 +1,29 @@
+import logging
+
+from extras.choices import JobResultStatusChoices
+from netbox.search.backends import search_backend
+from .choices import *
+from .exceptions import SyncError
+from .models import DataSource
+
+logger = logging.getLogger(__name__)
+
+
+def sync_datasource(job_result, *args, **kwargs):
+    """
+    Call sync() on a DataSource.
+    """
+    datasource = DataSource.objects.get(name=job_result.name)
+
+    try:
+        job_result.start()
+        datasource.sync()
+
+        # Update the search cache for DataFiles belonging to this source
+        search_backend.cache(datasource.datafiles.iterator())
+
+    except SyncError as e:
+        job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
+        job_result.save()
+        DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
+        logging.error(e)

+ 0 - 0
netbox/core/management/__init__.py


+ 0 - 0
netbox/core/management/commands/__init__.py


+ 41 - 0
netbox/core/management/commands/syncdatasource.py

@@ -0,0 +1,41 @@
+from django.core.management.base import BaseCommand, CommandError
+
+from core.models import DataSource
+
+
+class Command(BaseCommand):
+    help = "Synchronize a data source from its remote upstream"
+
+    def add_arguments(self, parser):
+        parser.add_argument('name', nargs='*', help="Data source(s) to synchronize")
+        parser.add_argument(
+            "--all", action='store_true', dest='sync_all',
+            help="Synchronize all data sources"
+        )
+
+    def handle(self, *args, **options):
+
+        # Find DataSources to sync
+        if options['sync_all']:
+            datasources = DataSource.objects.all()
+        elif options['name']:
+            datasources = DataSource.objects.filter(name__in=options['name'])
+            # Check for invalid names
+            found_names = {ds['name'] for ds in datasources.values('name')}
+            if invalid_names := set(options['name']) - found_names:
+                raise CommandError(f"Invalid data source names: {', '.join(invalid_names)}")
+        else:
+            raise CommandError(f"Must specify at least one data source, or set --all.")
+
+        if len(options['name']) > 1:
+            self.stdout.write(f"Syncing {len(datasources)} data sources.")
+
+        for i, datasource in enumerate(datasources, start=1):
+            self.stdout.write(f"[{i}] Syncing {datasource}... ", ending='')
+            self.stdout.flush()
+            datasource.sync()
+            self.stdout.write(datasource.get_status_display())
+            self.stdout.flush()
+
+        if len(options['name']) > 1:
+            self.stdout.write(f"Finished.")

+ 62 - 0
netbox/core/migrations/0001_initial.py

@@ -0,0 +1,62 @@
+# Generated by Django 4.1.5 on 2023-02-02 02:37
+
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+import taggit.managers
+import utilities.json
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ('extras', '0084_staging'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='DataSource',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('created', models.DateTimeField(auto_now_add=True, null=True)),
+                ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+                ('description', models.CharField(blank=True, max_length=200)),
+                ('comments', models.TextField(blank=True)),
+                ('name', models.CharField(max_length=100, unique=True)),
+                ('type', models.CharField(default='local', max_length=50)),
+                ('source_url', models.CharField(max_length=200)),
+                ('status', models.CharField(default='new', editable=False, max_length=50)),
+                ('enabled', models.BooleanField(default=True)),
+                ('ignore_rules', models.TextField(blank=True)),
+                ('parameters', models.JSONField(blank=True, null=True)),
+                ('last_synced', models.DateTimeField(blank=True, editable=False, null=True)),
+                ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+            ],
+            options={
+                'ordering': ('name',),
+            },
+        ),
+        migrations.CreateModel(
+            name='DataFile',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('created', models.DateTimeField(auto_now_add=True, null=True)),
+                ('path', models.CharField(editable=False, max_length=1000)),
+                ('last_updated', models.DateTimeField(editable=False)),
+                ('size', models.PositiveIntegerField(editable=False)),
+                ('hash', models.CharField(editable=False, max_length=64, validators=[django.core.validators.RegexValidator(message='Length must be 64 hexadecimal characters.', regex='^[0-9a-f]{64}$')])),
+                ('data', models.BinaryField()),
+                ('source', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='datafiles', to='core.datasource')),
+            ],
+            options={
+                'ordering': ('source', 'path'),
+            },
+        ),
+        migrations.AddConstraint(
+            model_name='datafile',
+            constraint=models.UniqueConstraint(fields=('source', 'path'), name='core_datafile_unique_source_path'),
+        ),
+    ]

+ 0 - 0
netbox/core/migrations/__init__.py


+ 1 - 0
netbox/core/models/__init__.py

@@ -0,0 +1 @@
+from .data import *

+ 302 - 0
netbox/core/models/data.py

@@ -0,0 +1,302 @@
+import logging
+import os
+from fnmatch import fnmatchcase
+from urllib.parse import urlparse
+
+from django.conf import settings
+from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ValidationError
+from django.core.validators import RegexValidator
+from django.db import models
+from django.urls import reverse
+from django.utils import timezone
+from django.utils.module_loading import import_string
+from django.utils.translation import gettext as _
+
+from extras.models import JobResult
+from netbox.models import PrimaryModel
+from netbox.models.features import ChangeLoggingMixin
+from netbox.registry import registry
+from utilities.files import sha256_hash
+from utilities.querysets import RestrictedQuerySet
+from ..choices import *
+from ..exceptions import SyncError
+from ..signals import post_sync, pre_sync
+
+__all__ = (
+    'DataFile',
+    'DataSource',
+)
+
+logger = logging.getLogger('netbox.core.data')
+
+
+class DataSource(PrimaryModel):
+    """
+    A remote source, such as a git repository, from which DataFiles are synchronized.
+    """
+    name = models.CharField(
+        max_length=100,
+        unique=True
+    )
+    type = models.CharField(
+        max_length=50,
+        choices=DataSourceTypeChoices,
+        default=DataSourceTypeChoices.LOCAL
+    )
+    source_url = models.CharField(
+        max_length=200,
+        verbose_name=_('URL')
+    )
+    status = models.CharField(
+        max_length=50,
+        choices=DataSourceStatusChoices,
+        default=DataSourceStatusChoices.NEW,
+        editable=False
+    )
+    enabled = models.BooleanField(
+        default=True
+    )
+    ignore_rules = models.TextField(
+        blank=True,
+        help_text=_("Patterns (one per line) matching files to ignore when syncing")
+    )
+    parameters = models.JSONField(
+        blank=True,
+        null=True
+    )
+    last_synced = models.DateTimeField(
+        blank=True,
+        null=True,
+        editable=False
+    )
+
+    class Meta:
+        ordering = ('name',)
+
+    def __str__(self):
+        return f'{self.name}'
+
+    def get_absolute_url(self):
+        return reverse('core:datasource', args=[self.pk])
+
+    @property
+    def docs_url(self):
+        return f'{settings.STATIC_URL}docs/models/{self._meta.app_label}/{self._meta.model_name}/'
+
+    def get_type_color(self):
+        return DataSourceTypeChoices.colors.get(self.type)
+
+    def get_status_color(self):
+        return DataSourceStatusChoices.colors.get(self.status)
+
+    @property
+    def url_scheme(self):
+        return urlparse(self.source_url).scheme.lower()
+
+    @property
+    def ready_for_sync(self):
+        return self.enabled and self.status not in (
+            DataSourceStatusChoices.QUEUED,
+            DataSourceStatusChoices.SYNCING
+        )
+
+    def clean(self):
+
+        # Ensure URL scheme matches selected type
+        if self.type == DataSourceTypeChoices.LOCAL and self.url_scheme not in ('file', ''):
+            raise ValidationError({
+                'url': f"URLs for local sources must start with file:// (or omit the scheme)"
+            })
+
+    def enqueue_sync_job(self, request):
+        """
+        Enqueue a background job to synchronize the DataSource by calling sync().
+        """
+        # Set the status to "syncing"
+        self.status = DataSourceStatusChoices.QUEUED
+
+        # Enqueue a sync job
+        job_result = JobResult.enqueue_job(
+            import_string('core.jobs.sync_datasource'),
+            name=self.name,
+            obj_type=ContentType.objects.get_for_model(DataSource),
+            user=request.user,
+        )
+
+        return job_result
+
+    def get_backend(self):
+        backend_cls = registry['data_backends'].get(self.type)
+        backend_params = self.parameters or {}
+
+        return backend_cls(self.source_url, **backend_params)
+
+    def sync(self):
+        """
+        Create/update/delete child DataFiles as necessary to synchronize with the remote source.
+        """
+        if not self.ready_for_sync:
+            raise SyncError(f"Cannot initiate sync; data source not ready/enabled")
+
+        # Emit the pre_sync signal
+        pre_sync.send(sender=self.__class__, instance=self)
+
+        self.status = DataSourceStatusChoices.SYNCING
+        DataSource.objects.filter(pk=self.pk).update(status=self.status)
+
+        # Replicate source data locally
+        backend = self.get_backend()
+        with backend.fetch() as local_path:
+
+            logger.debug(f'Syncing files from source root {local_path}')
+            data_files = self.datafiles.all()
+            known_paths = {df.path for df in data_files}
+            logger.debug(f'Starting with {len(known_paths)} known files')
+
+            # Check for any updated/deleted files
+            updated_files = []
+            deleted_file_ids = []
+            for datafile in data_files:
+
+                try:
+                    if datafile.refresh_from_disk(source_root=local_path):
+                        updated_files.append(datafile)
+                except FileNotFoundError:
+                    # File no longer exists
+                    deleted_file_ids.append(datafile.pk)
+                    continue
+
+            # Bulk update modified files
+            updated_count = DataFile.objects.bulk_update(updated_files, ['hash'])
+            logger.debug(f"Updated {updated_count} files")
+
+            # Bulk delete deleted files
+            deleted_count, _ = DataFile.objects.filter(pk__in=deleted_file_ids).delete()
+            logger.debug(f"Deleted {updated_count} files")
+
+            # Walk the local replication to find new files
+            new_paths = self._walk(local_path) - known_paths
+
+            # Bulk create new files
+            new_datafiles = []
+            for path in new_paths:
+                datafile = DataFile(source=self, path=path)
+                datafile.refresh_from_disk(source_root=local_path)
+                datafile.full_clean()
+                new_datafiles.append(datafile)
+            created_count = len(DataFile.objects.bulk_create(new_datafiles, batch_size=100))
+            logger.debug(f"Created {created_count} data files")
+
+        # Update status & last_synced time
+        self.status = DataSourceStatusChoices.COMPLETED
+        self.last_synced = timezone.now()
+        DataSource.objects.filter(pk=self.pk).update(status=self.status, last_synced=self.last_synced)
+
+        # Emit the post_sync signal
+        post_sync.send(sender=self.__class__, instance=self)
+
+    def _walk(self, root):
+        """
+        Return a set of all non-excluded files within the root path.
+        """
+        logger.debug(f"Walking {root}...")
+        paths = set()
+
+        for path, dir_names, file_names in os.walk(root):
+            path = path.split(root)[1].lstrip('/')  # Strip root path
+            if path.startswith('.'):
+                continue
+            for file_name in file_names:
+                if not self._ignore(file_name):
+                    paths.add(os.path.join(path, file_name))
+
+        logger.debug(f"Found {len(paths)} files")
+        return paths
+
+    def _ignore(self, filename):
+        """
+        Returns a boolean indicating whether the file should be ignored per the DataSource's configured
+        ignore rules.
+        """
+        if filename.startswith('.'):
+            return True
+        for rule in self.ignore_rules.splitlines():
+            if fnmatchcase(filename, rule):
+                return True
+        return False
+
+
+class DataFile(ChangeLoggingMixin, models.Model):
+    """
+    The database representation of a remote file fetched from a remote DataSource. DataFile instances should be created,
+    updated, or deleted only by calling DataSource.sync().
+    """
+    source = models.ForeignKey(
+        to='core.DataSource',
+        on_delete=models.CASCADE,
+        related_name='datafiles',
+        editable=False
+    )
+    path = models.CharField(
+        max_length=1000,
+        editable=False,
+        help_text=_("File path relative to the data source's root")
+    )
+    last_updated = models.DateTimeField(
+        editable=False
+    )
+    size = models.PositiveIntegerField(
+        editable=False
+    )
+    hash = models.CharField(
+        max_length=64,
+        editable=False,
+        validators=[
+            RegexValidator(regex='^[0-9a-f]{64}$', message=_("Length must be 64 hexadecimal characters."))
+        ],
+        help_text=_("SHA256 hash of the file data")
+    )
+    data = models.BinaryField()
+
+    objects = RestrictedQuerySet.as_manager()
+
+    class Meta:
+        ordering = ('source', 'path')
+        constraints = (
+            models.UniqueConstraint(
+                fields=('source', 'path'),
+                name='%(app_label)s_%(class)s_unique_source_path'
+            ),
+        )
+
+    def __str__(self):
+        return self.path
+
+    def get_absolute_url(self):
+        return reverse('core:datafile', args=[self.pk])
+
+    @property
+    def data_as_string(self):
+        try:
+            return self.data.tobytes().decode('utf-8')
+        except UnicodeDecodeError:
+            return None
+
+    def refresh_from_disk(self, source_root):
+        """
+        Update instance attributes from the file on disk. Returns True if any attribute
+        has changed.
+        """
+        file_path = os.path.join(source_root, self.path)
+        file_hash = sha256_hash(file_path).hexdigest()
+
+        # Update instance file attributes & data
+        if is_modified := file_hash != self.hash:
+            self.last_updated = timezone.now()
+            self.size = os.path.getsize(file_path)
+            self.hash = file_hash
+            with open(file_path, 'rb') as f:
+                self.data = f.read()
+
+        return is_modified

+ 21 - 0
netbox/core/search.py

@@ -0,0 +1,21 @@
+from netbox.search import SearchIndex, register_search
+from . import models
+
+
+@register_search
+class DataSourceIndex(SearchIndex):
+    model = models.DataSource
+    fields = (
+        ('name', 100),
+        ('source_url', 300),
+        ('description', 500),
+        ('comments', 5000),
+    )
+
+
+@register_search
+class DataFileIndex(SearchIndex):
+    model = models.DataFile
+    fields = (
+        ('path', 200),
+    )

+ 10 - 0
netbox/core/signals.py

@@ -0,0 +1,10 @@
+import django.dispatch
+
+__all__ = (
+    'post_sync',
+    'pre_sync',
+)
+
+# DataSource signals
+pre_sync = django.dispatch.Signal()
+post_sync = django.dispatch.Signal()

+ 1 - 0
netbox/core/tables/__init__.py

@@ -0,0 +1 @@
+from .data import *

+ 52 - 0
netbox/core/tables/data.py

@@ -0,0 +1,52 @@
+import django_tables2 as tables
+
+from core.models import *
+from netbox.tables import NetBoxTable, columns
+
+__all__ = (
+    'DataFileTable',
+    'DataSourceTable',
+)
+
+
+class DataSourceTable(NetBoxTable):
+    name = tables.Column(
+        linkify=True
+    )
+    type = columns.ChoiceFieldColumn()
+    status = columns.ChoiceFieldColumn()
+    enabled = columns.BooleanColumn()
+    tags = columns.TagColumn(
+        url_name='core:datasource_list'
+    )
+    file_count = tables.Column(
+        verbose_name='Files'
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = DataSource
+        fields = (
+            'pk', 'id', 'name', 'type', 'status', 'enabled', 'source_url', 'description', 'comments', 'parameters', 'created',
+            'last_updated', 'file_count',
+        )
+        default_columns = ('pk', 'name', 'type', 'status', 'enabled', 'description', 'file_count')
+
+
+class DataFileTable(NetBoxTable):
+    source = tables.Column(
+        linkify=True
+    )
+    path = tables.Column(
+        linkify=True
+    )
+    last_updated = columns.DateTimeColumn()
+    actions = columns.ActionsColumn(
+        actions=('delete',)
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = DataFile
+        fields = (
+            'pk', 'id', 'source', 'path', 'last_updated', 'size', 'hash',
+        )
+        default_columns = ('pk', 'source', 'path', 'size', 'last_updated')

+ 0 - 0
netbox/core/tests/__init__.py


+ 93 - 0
netbox/core/tests/test_api.py

@@ -0,0 +1,93 @@
+from django.urls import reverse
+from django.utils import timezone
+
+from utilities.testing import APITestCase, APIViewTestCases
+from ..choices import *
+from ..models import *
+
+
+class AppTest(APITestCase):
+
+    def test_root(self):
+        url = reverse('core-api:api-root')
+        response = self.client.get('{}?format=api'.format(url), **self.header)
+
+        self.assertEqual(response.status_code, 200)
+
+
+class DataSourceTest(APIViewTestCases.APIViewTestCase):
+    model = DataSource
+    brief_fields = ['display', 'id', 'name', 'url']
+    bulk_update_data = {
+        'enabled': False,
+        'description': 'foo bar baz',
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+        data_sources = (
+            DataSource(name='Data Source 1', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source1/'),
+            DataSource(name='Data Source 2', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source2/'),
+            DataSource(name='Data Source 3', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source3/'),
+        )
+        DataSource.objects.bulk_create(data_sources)
+
+        cls.create_data = [
+            {
+                'name': 'Data Source 4',
+                'type': DataSourceTypeChoices.GIT,
+                'source_url': 'https://example.com/git/source4'
+            },
+            {
+                'name': 'Data Source 5',
+                'type': DataSourceTypeChoices.GIT,
+                'source_url': 'https://example.com/git/source5'
+            },
+            {
+                'name': 'Data Source 6',
+                'type': DataSourceTypeChoices.GIT,
+                'source_url': 'https://example.com/git/source6'
+            },
+        ]
+
+
+class DataFileTest(
+    APIViewTestCases.GetObjectViewTestCase,
+    APIViewTestCases.ListObjectsViewTestCase,
+    APIViewTestCases.GraphQLTestCase
+):
+    model = DataFile
+    brief_fields = ['display', 'id', 'path', 'url']
+
+    @classmethod
+    def setUpTestData(cls):
+        datasource = DataSource.objects.create(
+            name='Data Source 1',
+            type=DataSourceTypeChoices.LOCAL,
+            source_url='file:///var/tmp/source1/'
+        )
+
+        data_files = (
+            DataFile(
+                source=datasource,
+                path='dir1/file1.txt',
+                last_updated=timezone.now(),
+                size=1000,
+                hash='442da078f0111cbdf42f21903724f6597c692535f55bdfbbea758a1ae99ad9e1'
+            ),
+            DataFile(
+                source=datasource,
+                path='dir1/file2.txt',
+                last_updated=timezone.now(),
+                size=2000,
+                hash='a78168c7c97115bafd96450ed03ea43acec495094c5caa28f0d02e20e3a76cc2'
+            ),
+            DataFile(
+                source=datasource,
+                path='dir1/file3.txt',
+                last_updated=timezone.now(),
+                size=3000,
+                hash='12b8827a14c4d5a2f30b6c6e2b7983063988612391c6cbe8ee7493b59054827a'
+            ),
+        )
+        DataFile.objects.bulk_create(data_files)

+ 120 - 0
netbox/core/tests/test_filtersets.py

@@ -0,0 +1,120 @@
+from datetime import datetime
+
+from django.test import TestCase
+from django.utils import timezone
+
+from utilities.testing import ChangeLoggedFilterSetTests
+from ..choices import *
+from ..filtersets import *
+from ..models import *
+
+
+class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = DataSource.objects.all()
+    filterset = DataSourceFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+        data_sources = (
+            DataSource(
+                name='Data Source 1',
+                type=DataSourceTypeChoices.LOCAL,
+                source_url='file:///var/tmp/source1/',
+                status=DataSourceStatusChoices.NEW,
+                enabled=True
+            ),
+            DataSource(
+                name='Data Source 2',
+                type=DataSourceTypeChoices.LOCAL,
+                source_url='file:///var/tmp/source2/',
+                status=DataSourceStatusChoices.SYNCING,
+                enabled=True
+            ),
+            DataSource(
+                name='Data Source 3',
+                type=DataSourceTypeChoices.GIT,
+                source_url='https://example.com/git/source3',
+                status=DataSourceStatusChoices.COMPLETED,
+                enabled=False
+            ),
+        )
+        DataSource.objects.bulk_create(data_sources)
+
+    def test_name(self):
+        params = {'name': ['Data Source 1', 'Data Source 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_type(self):
+        params = {'type': [DataSourceTypeChoices.LOCAL]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_enabled(self):
+        params = {'enabled': 'true'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'enabled': 'false'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_status(self):
+        params = {'status': [DataSourceStatusChoices.NEW, DataSourceStatusChoices.SYNCING]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = DataFile.objects.all()
+    filterset = DataFileFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+        data_sources = (
+            DataSource(name='Data Source 1', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source1/'),
+            DataSource(name='Data Source 2', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source2/'),
+            DataSource(name='Data Source 3', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source3/'),
+        )
+        DataSource.objects.bulk_create(data_sources)
+
+        data_files = (
+            DataFile(
+                source=data_sources[0],
+                path='dir1/file1.txt',
+                last_updated=datetime(2023, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
+                size=1000,
+                hash='442da078f0111cbdf42f21903724f6597c692535f55bdfbbea758a1ae99ad9e1'
+            ),
+            DataFile(
+                source=data_sources[1],
+                path='dir1/file2.txt',
+                last_updated=datetime(2023, 1, 2, 0, 0, 0, tzinfo=timezone.utc),
+                size=2000,
+                hash='a78168c7c97115bafd96450ed03ea43acec495094c5caa28f0d02e20e3a76cc2'
+            ),
+            DataFile(
+                source=data_sources[2],
+                path='dir1/file3.txt',
+                last_updated=datetime(2023, 1, 3, 0, 0, 0, tzinfo=timezone.utc),
+                size=3000,
+                hash='12b8827a14c4d5a2f30b6c6e2b7983063988612391c6cbe8ee7493b59054827a'
+            ),
+        )
+        DataFile.objects.bulk_create(data_files)
+
+    def test_source(self):
+        sources = DataSource.objects.all()
+        params = {'source_id': [sources[0].pk, sources[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'source': [sources[0].name, sources[1].name]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_path(self):
+        params = {'path': ['dir1/file1.txt', 'dir1/file2.txt']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_size(self):
+        params = {'size': [1000, 2000]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_hash(self):
+        params = {'hash': [
+            '442da078f0111cbdf42f21903724f6597c692535f55bdfbbea758a1ae99ad9e1',
+            'a78168c7c97115bafd96450ed03ea43acec495094c5caa28f0d02e20e3a76cc2',
+        ]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

+ 91 - 0
netbox/core/tests/test_views.py

@@ -0,0 +1,91 @@
+from django.utils import timezone
+
+from utilities.testing import ViewTestCases, create_tags
+from ..choices import *
+from ..models import *
+
+
+class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+    model = DataSource
+
+    @classmethod
+    def setUpTestData(cls):
+        data_sources = (
+            DataSource(name='Data Source 1', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source1/'),
+            DataSource(name='Data Source 2', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source2/'),
+            DataSource(name='Data Source 3', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source3/'),
+        )
+        DataSource.objects.bulk_create(data_sources)
+
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+        cls.form_data = {
+            'name': 'Data Source X',
+            'type': DataSourceTypeChoices.GIT,
+            'source_url': 'http:///exmaple/com/foo/bar/',
+            'description': 'Something',
+            'comments': 'Foo bar baz',
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.csv_data = (
+            f"name,type,source_url,enabled",
+            f"Data Source 4,{DataSourceTypeChoices.LOCAL},file:///var/tmp/source4/,true",
+            f"Data Source 5,{DataSourceTypeChoices.LOCAL},file:///var/tmp/source4/,true",
+            f"Data Source 6,{DataSourceTypeChoices.GIT},http:///exmaple/com/foo/bar/,false",
+        )
+
+        cls.csv_update_data = (
+            "id,name,description",
+            f"{data_sources[0].pk},Data Source 7,New description7",
+            f"{data_sources[1].pk},Data Source 8,New description8",
+            f"{data_sources[2].pk},Data Source 9,New description9",
+        )
+
+        cls.bulk_edit_data = {
+            'enabled': False,
+            'description': 'New description',
+        }
+
+
+class DataFileTestCase(
+    ViewTestCases.GetObjectViewTestCase,
+    ViewTestCases.GetObjectChangelogViewTestCase,
+    ViewTestCases.DeleteObjectViewTestCase,
+    ViewTestCases.ListObjectsViewTestCase,
+    ViewTestCases.BulkDeleteObjectsViewTestCase,
+):
+    model = DataFile
+
+    @classmethod
+    def setUpTestData(cls):
+        datasource = DataSource.objects.create(
+            name='Data Source 1',
+            type=DataSourceTypeChoices.LOCAL,
+            source_url='file:///var/tmp/source1/'
+        )
+
+        data_files = (
+            DataFile(
+                source=datasource,
+                path='dir1/file1.txt',
+                last_updated=timezone.now(),
+                size=1000,
+                hash='442da078f0111cbdf42f21903724f6597c692535f55bdfbbea758a1ae99ad9e1'
+            ),
+            DataFile(
+                source=datasource,
+                path='dir1/file2.txt',
+                last_updated=timezone.now(),
+                size=2000,
+                hash='a78168c7c97115bafd96450ed03ea43acec495094c5caa28f0d02e20e3a76cc2'
+            ),
+            DataFile(
+                source=datasource,
+                path='dir1/file3.txt',
+                last_updated=timezone.now(),
+                size=3000,
+                hash='12b8827a14c4d5a2f30b6c6e2b7983063988612391c6cbe8ee7493b59054827a'
+            ),
+        )
+        DataFile.objects.bulk_create(data_files)

+ 22 - 0
netbox/core/urls.py

@@ -0,0 +1,22 @@
+from django.urls import include, path
+
+from utilities.urls import get_model_urls
+from . import views
+
+app_name = 'core'
+urlpatterns = (
+
+    # Data sources
+    path('data-sources/', views.DataSourceListView.as_view(), name='datasource_list'),
+    path('data-sources/add/', views.DataSourceEditView.as_view(), name='datasource_add'),
+    path('data-sources/import/', views.DataSourceBulkImportView.as_view(), name='datasource_import'),
+    path('data-sources/edit/', views.DataSourceBulkEditView.as_view(), name='datasource_bulk_edit'),
+    path('data-sources/delete/', views.DataSourceBulkDeleteView.as_view(), name='datasource_bulk_delete'),
+    path('data-sources/<int:pk>/', include(get_model_urls('core', 'datasource'))),
+
+    # Data files
+    path('data-files/', views.DataFileListView.as_view(), name='datafile_list'),
+    path('data-files/delete/', views.DataFileBulkDeleteView.as_view(), name='datafile_bulk_delete'),
+    path('data-files/<int:pk>/', include(get_model_urls('core', 'datafile'))),
+
+)

+ 118 - 0
netbox/core/views.py

@@ -0,0 +1,118 @@
+from django.contrib import messages
+from django.shortcuts import get_object_or_404, redirect
+
+from netbox.views import generic
+from netbox.views.generic.base import BaseObjectView
+from utilities.utils import count_related
+from utilities.views import register_model_view
+from . import filtersets, forms, tables
+from .models import *
+
+
+#
+# Data sources
+#
+
+class DataSourceListView(generic.ObjectListView):
+    queryset = DataSource.objects.annotate(
+        file_count=count_related(DataFile, 'source')
+    )
+    filterset = filtersets.DataSourceFilterSet
+    filterset_form = forms.DataSourceFilterForm
+    table = tables.DataSourceTable
+
+
+@register_model_view(DataSource)
+class DataSourceView(generic.ObjectView):
+    queryset = DataSource.objects.all()
+
+    def get_extra_context(self, request, instance):
+        related_models = (
+            (DataFile.objects.restrict(request.user, 'view').filter(source=instance), 'source_id'),
+        )
+
+        return {
+            'related_models': related_models,
+        }
+
+
+@register_model_view(DataSource, 'sync')
+class DataSourceSyncView(BaseObjectView):
+    queryset = DataSource.objects.all()
+
+    def get_required_permission(self):
+        return 'core.sync_datasource'
+
+    def get(self, request, pk):
+        # Redirect GET requests to the object view
+        datasource = get_object_or_404(self.queryset, pk=pk)
+        return redirect(datasource.get_absolute_url())
+
+    def post(self, request, pk):
+        datasource = get_object_or_404(self.queryset, pk=pk)
+        job_result = datasource.enqueue_sync_job(request)
+
+        messages.success(request, f"Queued job #{job_result.pk} to sync {datasource}")
+        return redirect(datasource.get_absolute_url())
+
+
+@register_model_view(DataSource, 'edit')
+class DataSourceEditView(generic.ObjectEditView):
+    queryset = DataSource.objects.all()
+    form = forms.DataSourceForm
+
+
+@register_model_view(DataSource, 'delete')
+class DataSourceDeleteView(generic.ObjectDeleteView):
+    queryset = DataSource.objects.all()
+
+
+class DataSourceBulkImportView(generic.BulkImportView):
+    queryset = DataSource.objects.all()
+    model_form = forms.DataSourceImportForm
+    table = tables.DataSourceTable
+
+
+class DataSourceBulkEditView(generic.BulkEditView):
+    queryset = DataSource.objects.annotate(
+        count_files=count_related(DataFile, 'source')
+    )
+    filterset = filtersets.DataSourceFilterSet
+    table = tables.DataSourceTable
+    form = forms.DataSourceBulkEditForm
+
+
+class DataSourceBulkDeleteView(generic.BulkDeleteView):
+    queryset = DataSource.objects.annotate(
+        count_files=count_related(DataFile, 'source')
+    )
+    filterset = filtersets.DataSourceFilterSet
+    table = tables.DataSourceTable
+
+
+#
+# Data files
+#
+
+class DataFileListView(generic.ObjectListView):
+    queryset = DataFile.objects.defer('data')
+    filterset = filtersets.DataFileFilterSet
+    filterset_form = forms.DataFileFilterForm
+    table = tables.DataFileTable
+    actions = ('bulk_delete',)
+
+
+@register_model_view(DataFile)
+class DataFileView(generic.ObjectView):
+    queryset = DataFile.objects.all()
+
+
+@register_model_view(DataFile, 'delete')
+class DataFileDeleteView(generic.ObjectDeleteView):
+    queryset = DataFile.objects.all()
+
+
+class DataFileBulkDeleteView(generic.BulkDeleteView):
+    queryset = DataFile.objects.defer('data')
+    filterset = filtersets.DataFileFilterSet
+    table = tables.DataFileTable

+ 1 - 1
netbox/extras/management/commands/nbshell.py

@@ -9,7 +9,7 @@ from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.management.base import BaseCommand
 from django.core.management.base import BaseCommand
 
 
-APPS = ('circuits', 'dcim', 'extras', 'ipam', 'tenancy', 'users', 'virtualization', 'wireless')
+APPS = ('circuits', 'core', 'dcim', 'extras', 'ipam', 'tenancy', 'users', 'virtualization', 'wireless')
 
 
 BANNER_TEXT = """### NetBox interactive shell ({node})
 BANNER_TEXT = """### NetBox interactive shell ({node})
 ### Python {python} | Django {django} | NetBox {netbox}
 ### Python {python} | Django {django} | NetBox {netbox}

+ 7 - 3
netbox/extras/models/models.py

@@ -11,6 +11,7 @@ from django.core.validators import MinValueValidator, ValidationError
 from django.db import models
 from django.db import models
 from django.http import HttpResponse, QueryDict
 from django.http import HttpResponse, QueryDict
 from django.urls import reverse
 from django.urls import reverse
+from django.urls.exceptions import NoReverseMatch
 from django.utils import timezone
 from django.utils import timezone
 from django.utils.formats import date_format
 from django.utils.formats import date_format
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
@@ -634,7 +635,7 @@ class JobResult(models.Model):
     def delete(self, *args, **kwargs):
     def delete(self, *args, **kwargs):
         super().delete(*args, **kwargs)
         super().delete(*args, **kwargs)
 
 
-        rq_queue_name = get_config().QUEUE_MAPPINGS.get(self.obj_type.name, RQ_QUEUE_DEFAULT)
+        rq_queue_name = get_config().QUEUE_MAPPINGS.get(self.obj_type.model, RQ_QUEUE_DEFAULT)
         queue = django_rq.get_queue(rq_queue_name)
         queue = django_rq.get_queue(rq_queue_name)
         job = queue.fetch_job(str(self.job_id))
         job = queue.fetch_job(str(self.job_id))
 
 
@@ -642,7 +643,10 @@ class JobResult(models.Model):
             job.cancel()
             job.cancel()
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
-        return reverse(f'extras:{self.obj_type.name}_result', args=[self.pk])
+        try:
+            return reverse(f'extras:{self.obj_type.model}_result', args=[self.pk])
+        except NoReverseMatch:
+            return None
 
 
     def get_status_color(self):
     def get_status_color(self):
         return JobResultStatusChoices.colors.get(self.status)
         return JobResultStatusChoices.colors.get(self.status)
@@ -693,7 +697,7 @@ class JobResult(models.Model):
             schedule_at: Schedule the job to be executed at the passed date and time
             schedule_at: Schedule the job to be executed at the passed date and time
             interval: Recurrence interval (in minutes)
             interval: Recurrence interval (in minutes)
         """
         """
-        rq_queue_name = get_config().QUEUE_MAPPINGS.get(obj_type.name, RQ_QUEUE_DEFAULT)
+        rq_queue_name = get_config().QUEUE_MAPPINGS.get(obj_type.model, RQ_QUEUE_DEFAULT)
         queue = django_rq.get_queue(rq_queue_name)
         queue = django_rq.get_queue(rq_queue_name)
         status = JobResultStatusChoices.STATUS_SCHEDULED if schedule_at else JobResultStatusChoices.STATUS_PENDING
         status = JobResultStatusChoices.STATUS_SCHEDULED if schedule_at else JobResultStatusChoices.STATUS_PENDING
         job_result: JobResult = JobResult.objects.create(
         job_result: JobResult = JobResult.objects.create(

+ 1 - 0
netbox/netbox/api/views.py

@@ -27,6 +27,7 @@ class APIRootView(APIView):
 
 
         return Response({
         return Response({
             'circuits': reverse('circuits-api:api-root', request=request, format=format),
             'circuits': reverse('circuits-api:api-root', request=request, format=format),
+            'core': reverse('core-api:api-root', request=request, format=format),
             'dcim': reverse('dcim-api:api-root', request=request, format=format),
             'dcim': reverse('dcim-api:api-root', request=request, format=format),
             'extras': reverse('extras-api:api-root', request=request, format=format),
             'extras': reverse('extras-api:api-root', request=request, format=format),
             'ipam': reverse('ipam-api:api-root', request=request, format=format),
             'ipam': reverse('ipam-api:api-root', request=request, format=format),

+ 2 - 0
netbox/netbox/graphql/schema.py

@@ -1,6 +1,7 @@
 import graphene
 import graphene
 
 
 from circuits.graphql.schema import CircuitsQuery
 from circuits.graphql.schema import CircuitsQuery
+from core.graphql.schema import CoreQuery
 from dcim.graphql.schema import DCIMQuery
 from dcim.graphql.schema import DCIMQuery
 from extras.graphql.schema import ExtrasQuery
 from extras.graphql.schema import ExtrasQuery
 from ipam.graphql.schema import IPAMQuery
 from ipam.graphql.schema import IPAMQuery
@@ -14,6 +15,7 @@ from wireless.graphql.schema import WirelessQuery
 class Query(
 class Query(
     UsersQuery,
     UsersQuery,
     CircuitsQuery,
     CircuitsQuery,
+    CoreQuery,
     DCIMQuery,
     DCIMQuery,
     ExtrasQuery,
     ExtrasQuery,
     IPAMQuery,
     IPAMQuery,

+ 1 - 0
netbox/netbox/navigation/menu.py

@@ -287,6 +287,7 @@ OTHER_MENU = Menu(
         MenuGroup(
         MenuGroup(
             label=_('Integrations'),
             label=_('Integrations'),
             items=(
             items=(
+                get_model_item('core', 'datasource', _('Data Sources')),
                 get_model_item('extras', 'webhook', _('Webhooks')),
                 get_model_item('extras', 'webhook', _('Webhooks')),
                 MenuItem(
                 MenuItem(
                     link='extras:report_list',
                     link='extras:report_list',

+ 2 - 1
netbox/netbox/registry.py

@@ -25,9 +25,10 @@ class Registry(dict):
 
 
 # Initialize the global registry
 # Initialize the global registry
 registry = Registry()
 registry = Registry()
+registry['data_backends'] = dict()
+registry['denormalized_fields'] = collections.defaultdict(list)
 registry['model_features'] = {
 registry['model_features'] = {
     feature: collections.defaultdict(set) for feature in EXTRAS_FEATURES
     feature: collections.defaultdict(set) for feature in EXTRAS_FEATURES
 }
 }
-registry['denormalized_fields'] = collections.defaultdict(list)
 registry['search'] = dict()
 registry['search'] = dict()
 registry['views'] = collections.defaultdict(dict)
 registry['views'] = collections.defaultdict(dict)

+ 1 - 0
netbox/netbox/settings.py

@@ -332,6 +332,7 @@ INSTALLED_APPS = [
     'social_django',
     'social_django',
     'taggit',
     'taggit',
     'timezone_field',
     'timezone_field',
+    'core',
     'circuits',
     'circuits',
     'dcim',
     'dcim',
     'ipam',
     'ipam',

+ 2 - 0
netbox/netbox/urls.py

@@ -42,6 +42,7 @@ _patterns = [
 
 
     # Apps
     # Apps
     path('circuits/', include('circuits.urls')),
     path('circuits/', include('circuits.urls')),
+    path('core/', include('core.urls')),
     path('dcim/', include('dcim.urls')),
     path('dcim/', include('dcim.urls')),
     path('extras/', include('extras.urls')),
     path('extras/', include('extras.urls')),
     path('ipam/', include('ipam.urls')),
     path('ipam/', include('ipam.urls')),
@@ -53,6 +54,7 @@ _patterns = [
     # API
     # API
     path('api/', APIRootView.as_view(), name='api-root'),
     path('api/', APIRootView.as_view(), name='api-root'),
     path('api/circuits/', include('circuits.api.urls')),
     path('api/circuits/', include('circuits.api.urls')),
+    path('api/core/', include('core.api.urls')),
     path('api/dcim/', include('dcim.api.urls')),
     path('api/dcim/', include('dcim.api.urls')),
     path('api/extras/', include('extras.api.urls')),
     path('api/extras/', include('extras.api.urls')),
     path('api/ipam/', include('ipam.api.urls')),
     path('api/ipam/', include('ipam.api.urls')),

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 3 - 1
netbox/project-static/src/htmx.ts

@@ -1,8 +1,10 @@
 import { getElements, isTruthy } from './util';
 import { getElements, isTruthy } from './util';
 import { initButtons } from './buttons';
 import { initButtons } from './buttons';
+import { initSelect } from './select';
 
 
 function initDepedencies(): void {
 function initDepedencies(): void {
-  for (const init of [initButtons]) {
+  console.log('initDepedencies()');
+  for (const init of [initButtons, initSelect]) {
     init();
     init();
   }
   }
 }
 }

+ 81 - 0
netbox/templates/core/datafile.html

@@ -0,0 +1,81 @@
+{% extends 'generic/object.html' %}
+{% load buttons %}
+{% load custom_links %}
+{% load helpers %}
+{% load perms %}
+{% load plugins %}
+
+{% block breadcrumbs %}
+  {{ block.super }}
+  <li class="breadcrumb-item"><a href="{% url 'core:datafile_list' %}?source_id={{ object.source.pk }}">{{ object.source }}</a></li>
+{% endblock %}
+
+{% block controls %}
+  <div class="controls">
+    <div class="control-group">
+      {% plugin_buttons object %}
+    </div>
+    {% if request.user|can_delete:object %}
+      {% delete_button object %}
+    {% endif %}
+    <div class="control-group">
+      {% custom_links object %}
+    </div>
+  </div>
+{% endblock controls %}
+
+{% block content %}
+  <div class="row mb-3">
+    <div class="col">
+      <div class="card">
+        <h5 class="card-header">Data File</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">Source</th>
+              <td><a href="{{ object.source.get_absolute_url }}">{{ object.source }}</a></td>
+            </tr>
+            <tr>
+              <th scope="row">Path</th>
+              <td>
+                <span class="font-monospace" id="datafile_path">{{ object.path }}</span>
+                <a class="btn btn-sm btn-primary copy-token" data-clipboard-target="#datafile_path" title="Copy to clipboard">
+                  <i class="mdi mdi-content-copy"></i>
+                </a>
+              </td>
+            </tr>
+            <tr>
+              <th scope="row">Last Updated</th>
+              <td>{{ object.last_updated }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Size</th>
+              <td>{{ object.size }} byte{{ object.size|pluralize }}</td>
+            </tr>
+            <tr>
+              <th scope="row">SHA256 Hash</th>
+              <td>
+              <span class="font-monospace" id="datafile_hash">{{ object.hash }}</span>
+                <a class="btn btn-sm btn-primary copy-token" data-clipboard-target="#datafile_hash" title="Copy to clipboard">
+                  <i class="mdi mdi-content-copy"></i>
+                </a>
+              </td>
+            </tr>
+          </table>
+        </div>
+      </div>
+      <div class="card">
+        <h5 class="card-header">Content</h5>
+        <div class="card-body">
+          <pre>{{ object.data_as_string }}</pre>
+        </div>
+      </div>
+      {% plugin_left_page object %}
+    </div>
+  </div>
+  <div class="row mb-3">
+    <div class="col col-md-12">
+      {% plugin_full_width_page object %}
+    </div>
+  </div>
+{% endblock %}

+ 114 - 0
netbox/templates/core/datasource.html

@@ -0,0 +1,114 @@
+{% extends 'generic/object.html' %}
+{% load static %}
+{% load helpers %}
+{% load plugins %}
+{% load render_table from django_tables2 %}
+
+{% block extra_controls %}
+  {% if perms.core.sync_datasource %}
+    {% if object.ready_for_sync %}
+      <form action="{% url 'core:datasource_sync' pk=object.pk %}" method="post">
+        {% csrf_token %}
+        <button type="submit" class="btn btn-sm btn-primary">
+          <i class="mdi mdi-sync" aria-hidden="true"></i> Sync
+        </button>
+      </form>
+    {% else %}
+      <button class="btn btn-sm btn-primary" disabled>
+        <i class="mdi mdi-sync" aria-hidden="true"></i> Sync
+      </button>
+    {% endif %}
+  {% endif %}
+{% endblock %}
+
+{% block content %}
+  <div class="row mb-3">
+    <div class="col col-md-6">
+      <div class="card">
+        <h5 class="card-header">Data Source</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">Name</th>
+              <td>{{ object.name }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Type</th>
+              <td>{{ object.get_type_display }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Enabled</th>
+              <td>{% checkmark object.enabled %}</td>
+            </tr>
+            <tr>
+              <th scope="row">Status</th>
+              <td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
+            </tr>
+            <tr>
+              <th scope="row">Last synced</th>
+              <td>{{ object.last_synced|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Description</th>
+              <td>{{ object.description|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">URL</th>
+              <td>
+                <a href="{{ object.url }}">{{ object.url }}</a>
+              </td>
+            </tr>
+            <tr>
+              <th scope="row">Ignore rules</th>
+              <td>
+                {% if object.ignore_rules %}
+                  <pre>{{ object.ignore_rules }}</pre>
+                {% else %}
+                  {{ ''|placeholder }}
+                {% endif %}</td>
+            </tr>
+          </table>
+        </div>
+      </div>
+      {% include 'inc/panels/tags.html' %}
+      {% include 'inc/panels/comments.html' %}
+      {% plugin_left_page object %}
+    </div>
+    <div class="col col-md-6">
+      <div class="card">
+        <h5 class="card-header">Backend</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            {% for name, field in object.get_backend.parameters.items %}
+              <tr>
+                <th scope="row">{{ field.label }}</th>
+                <td>{{ object.parameters|get_key:name|placeholder }}</td>
+              </tr>
+            {% empty %}
+              <tr>
+                <td colspan="2" class="text-muted">
+                  No parameters defined
+                </td>
+              </tr>
+            {% endfor %}
+          </table>
+        </div>
+      </div>
+      {% include 'inc/panels/related_objects.html' %}
+      {% include 'inc/panels/custom_fields.html' %}
+      {% plugin_right_page object %}
+    </div>
+  </div>
+  <div class="row mb-3">
+    <div class="col col-md-12">
+      <div class="card">
+        <h5 class="card-header">Files</h5>
+        <div class="card-body htmx-container table-responsive"
+          hx-get="{% url 'core:datafile_list' %}?source_id={{ object.pk }}"
+          hx-trigger="load"
+        ></div>
+      </div>
+      {% plugin_full_width_page object %}
+    </div>
+  </div>
+{% endblock %}

+ 9 - 0
netbox/utilities/files.py

@@ -0,0 +1,9 @@
+import hashlib
+
+
+def sha256_hash(filepath):
+    """
+    Return the SHA256 hash of the file at the specified path.
+    """
+    with open(filepath, 'rb') as f:
+        return hashlib.sha256(f.read())

Некоторые файлы не были показаны из-за большого количества измененных файлов