VYPR
Medium severity5.3OSV Advisory· Published Oct 22, 2025· Updated Apr 15, 2026

CVE-2025-62607

CVE-2025-62607

Description

Nautobot Single Source of Truth (SSoT) is an app for Nautobot. Prior to version 3.10.0, an unauthenticated attacker could access this page to view the Service Now public instance name e.g. companyname.service-now.com. This is considered low-value information. This does not expose the Secret, the Secret Name, or the Secret Value for the Username/Password for Service-Now.com. An unauthenticated member would not be able to change the instance name, nor set a Secret. There is not a way to gain access to other pages Nautobot through the unauthenticated Configuration page. This issue has been patched in version 3.10.0.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
nautobot-ssotPyPI
< 3.10.03.10.0

Affected products

1

Patches

1
1530d25cdeb9

Component UI and UIViewSet changes (#991)

https://github.com/nautobot/nautobot-app-ssotStephen KielyOct 21, 2025via ghsa
42 files changed · +1535 2127
  • changes/991.housekeeping+1 0 added
    @@ -0,0 +1 @@
    +Implemented UIViewSets and Component UI.
    
  • .github/workflows/ci.yml+6 9 modified
    @@ -107,7 +107,7 @@ jobs:
           fail-fast: true
           matrix:
             python-version: ["3.11"]
    -        nautobot-version: ["2.4.2"]
    +        nautobot-version: ["2.4.20"]
         env:
           INVOKE_NAUTOBOT_SSOT_PYTHON_VER: "${{ matrix.python-version }}"
           INVOKE_NAUTOBOT_SSOT_NAUTOBOT_VER: "${{ matrix.nautobot-version }}"
    @@ -123,8 +123,7 @@ jobs:
           - name: "Constrain Nautobot version and regenerate lock file"
             env:
               INVOKE_NAUTOBOT_SSOT_LOCAL: "true"
    -          PY_CONSTRAINT: "${{ matrix.python-version == '3.9' && '3.9.2' || matrix.python-version }}"
    -        run: "poetry run invoke lock --constrain-nautobot-ver --constrain-python-ver=${{ env.PY_CONSTRAINT }}"
    +        run: "poetry run invoke lock --constrain-nautobot-ver --constrain-python-ver=${{ matrix.python-version }}"
           - name: "Set up Docker Buildx"
             id: "buildx"
             uses: "docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2"  # v3.10.0
    @@ -155,13 +154,13 @@ jobs:
         strategy:
           fail-fast: true
           matrix:
    -        python-version: ["3.9"]  # 3.12 stable is tested in unittest_report stage.
    +        python-version: ["3.10"]  # 3.12 stable is tested in unittest_report stage.
             db-backend: ["postgresql"]
             nautobot-version: ["stable"]
             include:
               - python-version: "3.11"
                 db-backend: "postgresql"
    -            nautobot-version: "2.4.2"
    +            nautobot-version: "2.4.20"
               - python-version: "3.12"
                 db-backend: "mysql"
                 nautobot-version: "stable"
    @@ -181,8 +180,7 @@ jobs:
           - name: "Constrain Nautobot version and regenerate lock file"
             env:
               INVOKE_NAUTOBOT_SSOT_LOCAL: "true"
    -          PY_CONSTRAINT: "${{ matrix.python-version == '3.9' && '3.9.2' || matrix.python-version }}"
    -        run: "poetry run invoke lock --constrain-nautobot-ver --constrain-python-ver=${{ env.PY_CONSTRAINT }}"
    +        run: "poetry run invoke lock --constrain-nautobot-ver --constrain-python-ver=${{ matrix.python-version }}"
           - name: "Set up Docker Buildx"
             id: "buildx"
             uses: "docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2"  # v3.10.0
    @@ -233,8 +231,7 @@ jobs:
           - name: "Constrain Nautobot version and regenerate lock file"
             env:
               INVOKE_NAUTOBOT_SSOT_LOCAL: "true"
    -          PY_CONSTRAINT: "${{ matrix.python-version == '3.9' && '3.9.2' || matrix.python-version }}"
    -        run: "poetry run invoke lock --constrain-nautobot-ver --constrain-python-ver=${{ env.PY_CONSTRAINT }}"
    +        run: "poetry run invoke lock --constrain-nautobot-ver --constrain-python-ver=${{ matrix.python-version }}"
           - name: "Set up Docker Buildx"
             id: "buildx"
             uses: "docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2"  # v3.10.0
    
  • nautobot_ssot/api/serializers.py+25 0 added
    @@ -0,0 +1,25 @@
    +"""Django API serializers for nautobot_ssot app."""
    +
    +from nautobot.apps.api import NautobotModelSerializer
    +
    +from nautobot_ssot import models
    +
    +
    +class SyncSerializer(NautobotModelSerializer):  # pylint: disable=too-many-ancestors
    +    """Sync Serializer."""
    +
    +    class Meta:
    +        """Meta attributes."""
    +
    +        model = models.Sync
    +        fields = "__all__"
    +
    +
    +class SyncLogEntrySerializer(NautobotModelSerializer):  # pylint: disable=too-many-ancestors
    +    """SyncLogEntry Serializer."""
    +
    +    class Meta:
    +        """Meta attributes."""
    +
    +        model = models.SyncLogEntry
    +        fields = "__all__"
    
  • nautobot_ssot/api/urls.py+4 0 modified
    @@ -2,11 +2,15 @@
     
     from nautobot.apps.api import OrderedDefaultRouter
     
    +from nautobot_ssot.api import views
     from nautobot_ssot.integrations.utils import each_enabled_integration_module
     
     urlpatterns = []
     router = OrderedDefaultRouter()
     
    +router.register("history", views.SyncViewSet)
    +router.register("logs", views.SyncLogEntryViewSet)
    +
     
     def _add_integrations():
         for module in each_enabled_integration_module("api.urls"):
    
  • nautobot_ssot/api/views.py+22 0 added
    @@ -0,0 +1,22 @@
    +"""API views for nautobot_ssot."""
    +
    +from nautobot.apps.api import NautobotModelViewSet
    +
    +from nautobot_ssot import filters, models
    +from nautobot_ssot.api import serializers
    +
    +
    +class SyncViewSet(NautobotModelViewSet):  # pylint: disable=too-many-ancestors
    +    """Sync viewset."""
    +
    +    queryset = models.Sync.objects.all()
    +    serializer_class = serializers.SyncSerializer
    +    filterset_class = filters.SyncFilterSet
    +
    +
    +class SyncLogEntryViewSet(NautobotModelViewSet):  # pylint: disable=too-many-ancestors
    +    """SyncLogEntry viewset."""
    +
    +    queryset = models.SyncLogEntry.objects.all()
    +    serializer_class = serializers.SyncLogEntrySerializer
    +    filterset_class = filters.SyncLogEntryFilterSet
    
  • nautobot_ssot/filters.py+8 2 modified
    @@ -1,6 +1,7 @@
     """Filtering logic for Sync and SyncLogEntry records."""
     
    -from nautobot.apps.filters import BaseFilterSet, SearchFilter
    +from django_filters import ModelMultipleChoiceFilter
    +from nautobot.apps.filters import BaseFilterSet, NautobotFilterSet, SearchFilter
     
     from nautobot_ssot import models
     from nautobot_ssot.integrations.infoblox.filters import SSOTInfobloxConfigFilterSet
    @@ -27,7 +28,7 @@ class Meta:
             fields = ["dry_run", "job_result"]  # pylint: disable=nb-use-fields-all
     
     
    -class SyncLogEntryFilterSet(BaseFilterSet):  # pylint: disable=too-many-ancestors
    +class SyncLogEntryFilterSet(NautobotFilterSet):  # pylint: disable=too-many-ancestors
         """Filter capabilities for SyncLogEntry instances."""
     
         q = SearchFilter(
    @@ -38,6 +39,11 @@ class SyncLogEntryFilterSet(BaseFilterSet):  # pylint: disable=too-many-ancestor
             }
         )
     
    +    sync = ModelMultipleChoiceFilter(
    +        queryset=models.Sync.objects.all(),
    +        label="Sync (name or ID)",
    +    )
    +
         class Meta:
             """Metaclass attributes of SyncLogEntryFilter."""
     
    
  • nautobot_ssot/forms.py+32 16 modified
    @@ -1,39 +1,40 @@
     """Forms for working with Sync and SyncLogEntry models."""
     
     from django import forms
    -from nautobot.apps.forms import BootstrapMixin, add_blank_choice
    +from nautobot.apps.forms import (
    +    BootstrapMixin,
    +    DynamicModelMultipleChoiceField,
    +    NautobotBulkEditForm,
    +    NautobotFilterForm,
    +    add_blank_choice,
    +)
     from nautobot.core.forms import BOOLEAN_WITH_BLANK_CHOICES
     
     from .choices import SyncLogEntryActionChoices, SyncLogEntryStatusChoices
     from .models import Sync, SyncLogEntry
     
     
    -class SyncFilterForm(BootstrapMixin, forms.ModelForm):
    +class SyncFilterForm(NautobotFilterForm):  # pylint: disable=too-many-ancestors
         """Form for filtering SyncOverview records."""
     
    +    model = Sync
    +    q = forms.CharField(required=False, label="Search")
         dry_run = forms.ChoiceField(choices=BOOLEAN_WITH_BLANK_CHOICES, required=False)
     
    -    class Meta:
    -        """Metaclass attributes of SyncFilterForm."""
    -
    -        model = Sync
    -        fields = ["dry_run"]
    -
     
    -class SyncLogEntryFilterForm(BootstrapMixin, forms.ModelForm):
    +class SyncLogEntryFilterForm(NautobotFilterForm):  # pylint: disable=too-many-ancestors
         """Form for filtering SyncLogEntry records."""
     
    +    model = SyncLogEntry
         q = forms.CharField(required=False, label="Search")
    -    sync = forms.ModelChoiceField(queryset=Sync.objects.defer("diff").all(), required=False)
    +    sync = DynamicModelMultipleChoiceField(
    +        queryset=Sync.objects.defer("diff").all(),
    +        required=False,
    +        label="Sync",
    +    )
         action = forms.ChoiceField(choices=add_blank_choice(SyncLogEntryActionChoices), required=False)
         status = forms.ChoiceField(choices=add_blank_choice(SyncLogEntryStatusChoices), required=False)
     
    -    class Meta:
    -        """Metaclass attributes of SyncLogEntryFilterForm."""
    -
    -        model = SyncLogEntry
    -        fields = ["sync", "action", "status"]
    -
     
     class SyncForm(BootstrapMixin, forms.Form):  # pylint: disable=nb-incorrect-base-class
         """Base class for dynamic form generation for a SyncWorker."""
    @@ -44,3 +45,18 @@ class SyncForm(BootstrapMixin, forms.Form):  # pylint: disable=nb-incorrect-base
             label="Dry run",
             help_text="Perform a dry run, making no actual changes to the database.",
         )
    +
    +
    +class SyncBulkEditForm(NautobotBulkEditForm):  # pylint: disable=too-many-ancestors
    +    """Form for bulk editing Sync records."""
    +
    +    dry_run = forms.NullBooleanField(
    +        required=False,
    +        label="Dry run",
    +        help_text="Perform a dry run, making no actual changes to the database.",
    +    )
    +
    +    class Meta:
    +        """Metaclass attributes of SyncBulkEditForm."""
    +
    +        model = Sync
    
  • nautobot_ssot/integrations/infoblox/models.py+17 8 modified
    @@ -51,13 +51,13 @@ class SSOTInfobloxConfig(PrimaryModel):  # pylint: disable=too-many-ancestors
         default_status = models.ForeignKey(
             to="extras.Status",
             on_delete=models.PROTECT,
    -        verbose_name="Default Object Status",
    -        help_text="Default Object Status",
    +        verbose_name="Default Status for Imported Objects",
    +        help_text="Default Status for Imported Objects",
         )
         infoblox_instance = models.ForeignKey(
             to="extras.ExternalIntegration",
             on_delete=models.PROTECT,
    -        verbose_name="Infoblox Instance Config",
    +        verbose_name="Infoblox Instance",
             help_text="Infoblox Instance",
         )
         infoblox_wapi_version = models.CharField(
    @@ -66,10 +66,14 @@ class SSOTInfobloxConfig(PrimaryModel):  # pylint: disable=too-many-ancestors
             verbose_name="Infoblox WAPI version",
         )
         enable_sync_to_infoblox = models.BooleanField(
    -        default=False, verbose_name="Sync to Infoblox", help_text="Enable syncing of data from Nautobot to Infoblox."
    +        default=False,
    +        verbose_name="Enable Sync from Nautobot to Infoblox",
    +        help_text="Enable syncing of data from Nautobot to Infoblox.",
         )
         enable_sync_to_nautobot = models.BooleanField(
    -        default=True, verbose_name="Sync to Nautobot", help_text="Enable syncing of data from Infoblox to Nautobot."
    +        default=True,
    +        verbose_name="Enable Sync from Infoblox to Nautobot",
    +        help_text="Enable syncing of data from Infoblox to Nautobot.",
         )
         import_ip_addresses = models.BooleanField(
             default=False,
    @@ -105,7 +109,7 @@ class SSOTInfobloxConfig(PrimaryModel):  # pylint: disable=too-many-ancestors
             max_length=CHARFIELD_MAX_LENGTH,
             default=DNSRecordTypeChoices.HOST_RECORD,
             choices=DNSRecordTypeChoices,
    -        verbose_name="DBS record type",
    +        verbose_name="Infoblox - DNS record type",
             help_text="Choose what type of Infoblox DNS record to create for IP Addresses.",
         )
         fixed_address_type = models.CharField(
    @@ -116,17 +120,22 @@ class SSOTInfobloxConfig(PrimaryModel):  # pylint: disable=too-many-ancestors
         )
         job_enabled = models.BooleanField(
             default=False,
    -        verbose_name="Enabled for Sync Job",
    +        verbose_name="Can be used in Sync Job",
             help_text="Enable use of this configuration in the sync jobs.",
         )
         infoblox_deletable_models = models.JSONField(
             encoder=DjangoJSONEncoder,
             default=list,
             blank=True,
    +        verbose_name="Infoblox - deletable models",
             help_text="Model types that can be deleted in Infoblox.",
         )
         nautobot_deletable_models = models.JSONField(
    -        encoder=DjangoJSONEncoder, default=list, blank=True, help_text="Model types that can be deleted in Nautobot."
    +        encoder=DjangoJSONEncoder,
    +        default=list,
    +        blank=True,
    +        help_text="Model types that can be deleted in Nautobot.",
    +        verbose_name="Nautobot - deletable models",
         )
     
         class Meta:
    
  • nautobot_ssot/integrations/infoblox/urls.py+2 16 modified
    @@ -1,26 +1,12 @@
     """URL patterns for nautobot-ssot-servicenow."""
     
    -from django.urls import path
     from nautobot.apps.urls import NautobotUIViewSetRouter
     
    -from . import models, views
    +from . import views
     
     router = NautobotUIViewSetRouter()
     router.register("config/infoblox", viewset=views.SSOTInfobloxConfigUIViewSet)
     
    -urlpatterns = [
    -    path(
    -        "config/infoblox/<uuid:pk>/changelog/",
    -        views.SSOTInfobloxConfigChangeLogView.as_view(),
    -        name="ssotinfobloxconfig_changelog",
    -        kwargs={"model": models.SSOTInfobloxConfig},
    -    ),
    -    path(
    -        "config/infoblox/<uuid:pk>/notes/",
    -        views.SSOTInfobloxConfigNotesView.as_view(),
    -        name="ssotinfobloxconfig_notes",
    -        kwargs={"model": models.SSOTInfobloxConfig},
    -    ),
    -]
    +urlpatterns = []
     
     urlpatterns += router.urls
    
  • nautobot_ssot/integrations/infoblox/views.py+86 29 modified
    @@ -1,12 +1,22 @@
     """Views implementation for SSOT Infoblox."""
     
    +from nautobot.apps.ui import (
    +    Breadcrumbs,
    +    ModelBreadcrumbItem,
    +    ObjectDetailContent,
    +    ObjectFieldsPanel,
    +    ObjectTextPanel,
    +    SectionChoices,
    +    ViewNameBreadcrumbItem,
    +)
     from nautobot.apps.views import (
    +    ObjectChangeLogViewMixin,
         ObjectDestroyViewMixin,
         ObjectDetailViewMixin,
         ObjectEditViewMixin,
         ObjectListViewMixin,
    +    ObjectNotesViewMixin,
     )
    -from nautobot.extras.views import ObjectChangeLogView, ObjectNotesView
     
     from .api.serializers import SSOTInfobloxConfigSerializer
     from .filters import SSOTInfobloxConfigFilterSet
    @@ -16,7 +26,12 @@
     
     
     class SSOTInfobloxConfigUIViewSet(
    -    ObjectDestroyViewMixin, ObjectDetailViewMixin, ObjectListViewMixin, ObjectEditViewMixin
    +    ObjectDestroyViewMixin,
    +    ObjectDetailViewMixin,
    +    ObjectListViewMixin,
    +    ObjectEditViewMixin,
    +    ObjectChangeLogViewMixin,
    +    ObjectNotesViewMixin,
     ):  # pylint: disable=abstract-method
         """SSOTInfobloxConfig UI ViewSet."""
     
    @@ -29,30 +44,72 @@ class SSOTInfobloxConfigUIViewSet(
         lookup_field = "pk"
         action_buttons = ("add",)
     
    -    def get_template_name(self):
    -        """Override inherited method to allow custom location for templates."""
    -        action = self.action
    -        app_label = "nautobot_ssot_infoblox"
    -        model_opts = self.queryset.model._meta
    -        if action in ["create", "update"]:
    -            template_name = f"{app_label}/{model_opts.model_name}_update.html"
    -        elif action == "retrieve":
    -            template_name = f"{app_label}/{model_opts.model_name}_retrieve.html"
    -        elif action == "list":
    -            template_name = f"{app_label}/{model_opts.model_name}_list.html"
    -        else:
    -            template_name = super().get_template_name()
    -
    -        return template_name
    -
    -
    -class SSOTInfobloxConfigChangeLogView(ObjectChangeLogView):
    -    """SSOTInfobloxConfig ChangeLog View."""
    -
    -    base_template = "nautobot_ssot_infoblox/ssotinfobloxconfig_retrieve.html"
    -
    -
    -class SSOTInfobloxConfigNotesView(ObjectNotesView):
    -    """SSOTInfobloxConfig Notes View."""
    -
    -    base_template = "nautobot_ssot_infoblox/ssotinfobloxconfig_retrieve.html"
    +    breadcrumbs = Breadcrumbs(
    +        items={
    +            "list": [
    +                ViewNameBreadcrumbItem(view_name="plugins:nautobot_ssot:dashboard", label="Single Source of Truth"),
    +                ViewNameBreadcrumbItem(view_name="plugins:nautobot_ssot:config", label="SSOT Configs"),
    +                ModelBreadcrumbItem(model=SSOTInfobloxConfig),
    +            ],
    +            "detail": [
    +                ViewNameBreadcrumbItem(view_name="plugins:nautobot_ssot:dashboard", label="Single Source of Truth"),
    +                ViewNameBreadcrumbItem(view_name="plugins:nautobot_ssot:config", label="SSOT Configs"),
    +                ModelBreadcrumbItem(model=SSOTInfobloxConfig),
    +            ],
    +        }
    +    )
    +    object_detail_content = ObjectDetailContent(
    +        panels=[
    +            ObjectFieldsPanel(
    +                weight=100,
    +                section=SectionChoices.LEFT_HALF,
    +                fields=[
    +                    "name",
    +                    "description",
    +                    "infoblox_instance",
    +                    "default_status",
    +                    "infoblox_wapi_version",
    +                    "job_enabled",
    +                    "enable_sync_to_infoblox",
    +                    "import_subnets",
    +                    "import_ip_addresses",
    +                    "import_vlans",
    +                    "import_vlan_views",
    +                    "import_ipv4",
    +                    "import_ipv6",
    +                    "fixed_address_type",
    +                    "dns_record_type",
    +                    "infoblox_deletable_models",
    +                    "nautobot_deletable_models",
    +                ],
    +            ),
    +            ObjectTextPanel(
    +                weight=200,
    +                section=SectionChoices.RIGHT_HALF,
    +                label="Infoblox Sync Filters",
    +                object_field="infoblox_sync_filters",
    +                render_as=ObjectTextPanel.RenderOptions.JSON,
    +            ),
    +            ObjectTextPanel(
    +                weight=300,
    +                section=SectionChoices.RIGHT_HALF,
    +                label="Infoblox Network View Namespace Map",
    +                object_field="infoblox_network_view_to_namespace_map",
    +                render_as=ObjectTextPanel.RenderOptions.JSON,
    +            ),
    +            ObjectTextPanel(
    +                weight=400,
    +                section=SectionChoices.RIGHT_HALF,
    +                label="Infoblox Network View to DNS View Mapping",
    +                object_field="infoblox_dns_view_mapping",
    +                render_as=ObjectTextPanel.RenderOptions.JSON,
    +            ),
    +            ObjectTextPanel(
    +                weight=500,
    +                section=SectionChoices.RIGHT_HALF,
    +                label="Extensible Attributes/Custom Fields to Ignore",
    +                object_field="cf_fields_ignore",
    +                render_as=ObjectTextPanel.RenderOptions.JSON,
    +            ),
    +        ]
    +    )
    
  • nautobot_ssot/integrations/itential/tables.py+11 2 modified
    @@ -14,11 +14,20 @@ class AutomationGatewayModelTable(BaseTable):
         """AutomationGatewayModel Table."""
     
         pk = ToggleColumn()
    -    name = tables.LinkColumn()
    +    name = tables.Column(linkify=True)
         actions = ButtonsColumn(models.AutomationGatewayModel)
     
    -    class Meta:
    +    class Meta(BaseTable.Meta):
             """Meta class definition."""
     
             model = models.AutomationGatewayModel
             fields = ["name", "description", "location", "location_descendants", "gateway", "enabled"]
    +        default_columns = (
    +            "name",
    +            "description",
    +            "location",
    +            "location_descendants",
    +            "gateway",
    +            "enabled",
    +            "actions",
    +        )
    
  • nautobot_ssot/integrations/itential/views.py+30 0 modified
    @@ -1,6 +1,14 @@
     """Itential SSoT Views."""
     
     from nautobot.apps import views
    +from nautobot.apps.ui import (
    +    Breadcrumbs,
    +    ModelBreadcrumbItem,
    +    ObjectDetailContent,
    +    ObjectFieldsPanel,
    +    SectionChoices,
    +    ViewNameBreadcrumbItem,
    +)
     
     from nautobot_ssot.integrations.itential import filters, forms, models, tables
     from nautobot_ssot.integrations.itential.api import serializers
    @@ -17,3 +25,25 @@ class AutomationGatewayModelUIViewSet(views.NautobotUIViewSet):
         serializer_class = serializers.AutomationGatewayModelSerializer
         table_class = tables.AutomationGatewayModelTable
         lookup_field = "pk"
    +    breadcrumbs = Breadcrumbs(
    +        items={
    +            "list": [
    +                ViewNameBreadcrumbItem(view_name="plugins:nautobot_ssot:dashboard", label="Single Source of Truth"),
    +                ModelBreadcrumbItem(model=models.AutomationGatewayModel),
    +            ],
    +            "detail": [
    +                ViewNameBreadcrumbItem(view_name="plugins:nautobot_ssot:dashboard", label="Single Source of Truth"),
    +                ModelBreadcrumbItem(),
    +            ],
    +        }
    +    )
    +
    +    object_detail_content = ObjectDetailContent(
    +        panels=(
    +            ObjectFieldsPanel(
    +                weight=100,
    +                section=SectionChoices.LEFT_HALF,
    +                fields="__all__",
    +            ),
    +        )
    +    )
    
  • nautobot_ssot/integrations/servicenow/models.py+8 8 modified
    @@ -2,7 +2,7 @@
     
     from django.db import models
     from django.shortcuts import reverse
    -from nautobot.core.models import BaseModel
    +from nautobot.apps.models import BaseModel
     
     
     class SSOTServiceNowConfig(BaseModel):  # pylint: disable=nb-string-field-blank-null
    @@ -11,13 +11,6 @@ class SSOTServiceNowConfig(BaseModel):  # pylint: disable=nb-string-field-blank-
         def delete(self, *args, **kwargs):
             """Cannot be deleted."""
     
    -    @classmethod
    -    def load(cls):
    -        """Singleton instance getter."""
    -        if cls.objects.all().exists():
    -            return cls.objects.first()
    -        return cls.objects.create()
    -
         servicenow_instance = models.CharField(
             max_length=100,
             blank=True,
    @@ -39,3 +32,10 @@ def __str__(self):
         def get_absolute_url(self, api=False):  # pylint: disable=unused-argument
             """Get URL for the associated configuration view."""
             return reverse("plugins:nautobot_ssot:servicenow_config")
    +
    +    @classmethod
    +    def load(cls):
    +        """Singleton instance getter."""
    +        if cls.objects.all().exists():
    +            return cls.objects.first()
    +        return cls.objects.create()
    
  • nautobot_ssot/integrations/servicenow/views.py+9 2 modified
    @@ -2,17 +2,20 @@
     
     from django.contrib import messages
     from django.views.generic import UpdateView
    +from nautobot.apps.utils import get_permission_for_model
    +from nautobot.apps.views import ObjectPermissionRequiredMixin
     from nautobot.core.forms import restrict_form_fields
     
     from .forms import SSOTServiceNowConfigForm
     from .models import SSOTServiceNowConfig
     
     
    -class SSOTServiceNowConfigView(UpdateView):
    +class SSOTServiceNowConfigView(ObjectPermissionRequiredMixin, UpdateView):
         """App configuration view for nautobot-ssot-servicenow."""
     
         form_class = SSOTServiceNowConfigForm
    -    template_name = "nautobot_ssot_servicenow/config.html"
    +    template_name = "nautobot_ssot/ssotservicenowconfig.html"
    +    queryset = SSOTServiceNowConfig.objects.all()
     
         def get_object(self, queryset=None):  # pylint: disable=unused-argument
             """Retrieve the SSOTServiceNowConfig singleton instance."""
    @@ -30,3 +33,7 @@ def form_valid(self, form):
             """Callback when the form is submitted successfully."""
             messages.success(self.request, "Successfully updated configuration")
             return super().form_valid(form)
    +
    +    def get_required_permission(self):
    +        """Return the required permission to access this view."""
    +        return get_permission_for_model(self.queryset.model, "change")
    
  • nautobot_ssot/integrations/vsphere/views.py+64 28 modified
    @@ -1,6 +1,15 @@
     """Views implementation for SSOT vSphere."""
     
     # pylint: disable=duplicate-code
    +from nautobot.apps.ui import (
    +    Breadcrumbs,
    +    ModelBreadcrumbItem,
    +    ObjectDetailContent,
    +    ObjectFieldsPanel,
    +    ObjectTextPanel,
    +    SectionChoices,
    +    ViewNameBreadcrumbItem,
    +)
     from nautobot.apps.views import (
         ObjectChangeLogViewMixin,
         ObjectDestroyViewMixin,
    @@ -9,7 +18,6 @@
         ObjectListViewMixin,
         ObjectNotesViewMixin,
     )
    -from nautobot.extras.views import ObjectChangeLogView, ObjectNotesView
     
     from .api.serializers import SSOTvSphereConfigSerializer
     from .filters import SSOTvSphereConfigFilterSet
    @@ -37,30 +45,58 @@ class SSOTvSphereConfigUIViewSet(
         lookup_field = "pk"
         action_buttons = ("add",)
     
    -    def get_template_name(self):
    -        """Override inherited method to allow custom location for templates."""
    -        action = self.action
    -        app_label = "nautobot_ssot_vsphere"
    -        model_opts = self.queryset.model._meta
    -        if action in ["create", "update"]:
    -            template_name = f"{app_label}/{model_opts.model_name}_update.html"
    -        elif action == "retrieve":
    -            template_name = f"{app_label}/{model_opts.model_name}_retrieve.html"
    -        elif action == "list":
    -            template_name = f"{app_label}/{model_opts.model_name}_list.html"
    -        else:
    -            template_name = super().get_template_name()
    -
    -        return template_name
    -
    -
    -class SSOTvSphereConfigChangeLogView(ObjectChangeLogView):
    -    """SSOTvSphereConfig ChangeLog View."""
    -
    -    base_template = "nautobot_ssot_infoblox/ssotvsphereconfig_retrieve.html"
    -
    -
    -class SSOTvSphereConfigNotesView(ObjectNotesView):
    -    """SSOTvSphereConfig Notes View."""
    -
    -    base_template = "nautobot_ssot_infoblox/ssotvsphereconfig_retrieve.html"
    +    breadcrumbs = Breadcrumbs(
    +        items={
    +            "list": [
    +                ViewNameBreadcrumbItem(view_name="plugins:nautobot_ssot:dashboard", label="Single Source of Truth"),
    +                ViewNameBreadcrumbItem(view_name="plugins:nautobot_ssot:config", label="SSOT Configs"),
    +                ModelBreadcrumbItem(model=SSOTvSphereConfig),
    +            ],
    +            "detail": [
    +                ViewNameBreadcrumbItem(view_name="plugins:nautobot_ssot:dashboard", label="Single Source of Truth"),
    +                ViewNameBreadcrumbItem(view_name="plugins:nautobot_ssot:config", label="SSOT Configs"),
    +                ModelBreadcrumbItem(model=SSOTvSphereConfig),
    +            ],
    +        }
    +    )
    +    object_detail_content = ObjectDetailContent(
    +        panels=[
    +            ObjectFieldsPanel(
    +                weight=100,
    +                section=SectionChoices.LEFT_HALF,
    +                fields=[
    +                    "name",
    +                    "description",
    +                    "vsphere_instance",
    +                    "default_ignore_link_local",
    +                    "use_clusters",
    +                    "primary_ip_sort_by",
    +                    "sync_tagged_only",
    +                    "default_clustergroup_name",
    +                    "default_cluster_name",
    +                    "default_cluster_type",
    +                ],
    +            ),
    +            ObjectTextPanel(
    +                weight=200,
    +                section=SectionChoices.RIGHT_HALF,
    +                label="vSphere Virtual Machine Status Mappings",
    +                object_field="default_vm_status_map",
    +                render_as=ObjectTextPanel.RenderOptions.JSON,
    +            ),
    +            ObjectTextPanel(
    +                weight=300,
    +                section=SectionChoices.RIGHT_HALF,
    +                label="vSphere Virtual Machine IP Status Mappings",
    +                object_field="default_ip_status_map",
    +                render_as=ObjectTextPanel.RenderOptions.JSON,
    +            ),
    +            ObjectTextPanel(
    +                weight=400,
    +                section=SectionChoices.RIGHT_HALF,
    +                label="vSphere Virtual Machine Interface Status Mappings",
    +                object_field="default_vm_interface_map",
    +                render_as=ObjectTextPanel.RenderOptions.JSON,
    +            ),
    +        ]
    +    )
    
  • nautobot_ssot/migrations/0016_alter_sync_options.py+20 0 added
    @@ -0,0 +1,20 @@
    +# Generated by Django 4.2.25 on 2025-10-20 13:35
    +
    +from django.db import migrations
    +
    +
    +class Migration(migrations.Migration):
    +    dependencies = [
    +        ("nautobot_ssot", "0015_ssotvsphereconfig"),
    +    ]
    +
    +    operations = [
    +        migrations.AlterModelOptions(
    +            name="sync",
    +            options={
    +                "ordering": ["start_time"],
    +                "verbose_name": "Data Sync",
    +                "verbose_name_plural": "SSoT Sync History",
    +            },
    +        ),
    +    ]
    
  • nautobot_ssot/models.py+49 2 modified
    @@ -26,6 +26,7 @@
     from django.db import models
     from django.urls import reverse
     from django.utils.formats import date_format
    +from django.utils.html import format_html
     from django.utils.timezone import now
     from nautobot.core.models import BaseModel
     from nautobot.extras.choices import JobResultStatusChoices
    @@ -35,6 +36,7 @@
     from nautobot_ssot.integrations.infoblox.models import SSOTInfobloxConfig
     from nautobot_ssot.integrations.itential.models import AutomationGatewayModel
     from nautobot_ssot.integrations.servicenow.models import SSOTServiceNowConfig
    +from nautobot_ssot.templatetags.shorter_timedelta import shorter_timedelta
     
     from .choices import SyncLogEntryActionChoices, SyncLogEntryStatusChoices
     
    @@ -58,8 +60,8 @@ class Sync(BaseModel):  # pylint: disable=nb-string-field-blank-null
         Essentially an extension of the JobResult model to add a few additional fields.
         """
     
    -    source = models.CharField(max_length=64, help_text="System data is read from")
    -    target = models.CharField(max_length=64, help_text="System data is written to")
    +    source = models.CharField(max_length=64, help_text="System data is read from", verbose_name="Data Source")
    +    target = models.CharField(max_length=64, help_text="System data is written to", verbose_name="Data Target")
     
         start_time = models.DateTimeField(blank=True, null=True, db_index=True)
         # end_time is represented by the job_result.date_done field
    @@ -79,6 +81,7 @@ class Sync(BaseModel):  # pylint: disable=nb-string-field-blank-null
         dry_run = models.BooleanField(
             default=False,
             help_text="Report what data would be synced but do not make any changes",
    +        verbose_name="Type",
         )
         diff = models.JSONField(blank=True, encoder=DiffJSONEncoder)
         summary = models.JSONField(blank=True, null=True)
    @@ -90,6 +93,8 @@ class Meta:
             """Metaclass attributes of Sync model."""
     
             ordering = ["start_time"]
    +        verbose_name = "Data Sync"
    +        verbose_name_plural = "SSoT Sync History"
     
         def __str__(self):
             """String representation of a Sync instance."""
    @@ -147,6 +152,13 @@ def duration(self):  # pylint: disable=inconsistent-return-statements
             if self.job_result and self.job_result.date_done:
                 return self.job_result.date_done - self.start_time
     
    +    @property
    +    def end_time(self):
    +        """End time of this Sync, if known."""
    +        if self.job_result and self.job_result.date_done:
    +            return self.job_result.date_done
    +        return None
    +
         def get_source_url(self):
             """Get the absolute url of the source worker associated with this instance."""
             if self.source == "Nautobot" or not self.job_result:
    @@ -156,6 +168,13 @@ def get_source_url(self):
                 kwargs={"class_path": self.job_result.job_model.class_path},
             )
     
    +    def get_source_display(self):
    +        """Display the name and link to the source worker associated with this instance."""
    +        source_url = self.get_source_url()
    +        if source_url:
    +            return format_html('<a href="{}">{}</a>', source_url, self.source)
    +        return self.source
    +
         def get_target_url(self):
             """Get the absolute url of the target worker associated with this instance."""
             if self.target == "Nautobot" or not self.job_result:
    @@ -165,6 +184,34 @@ def get_target_url(self):
                 kwargs={"class_path": self.job_result.job_model.class_path},
             )
     
    +    def get_target_display(self):
    +        """Display the name and link to the target worker associated with this instance."""
    +        target_url = self.get_target_url()
    +        if target_url:
    +            return format_html('<a href="{}">{}</a>', target_url, self.target)
    +        return self.target
    +
    +    def get_duration_display(self):
    +        """Display the duration of this Sync and each phase."""
    +        return format_html(
    +            """
    +                {} total
    +                <ul>
    +                    <li>{} loading from {}</li>
    +                    <li>{} loading from {}</li>
    +                    <li>{} calculating diffs</li>
    +                    <li>{} performing sync</li>
    +                </ul>
    +            """,
    +            shorter_timedelta(self.duration),
    +            shorter_timedelta(self.source_load_time),
    +            self.source,
    +            shorter_timedelta(self.target_load_time),
    +            self.target,
    +            shorter_timedelta(self.diff_time),
    +            shorter_timedelta(self.sync_time),
    +        )
    +
     
     class SyncLogEntry(BaseModel):  # pylint: disable=nb-string-field-blank-null
         """Record of a single event during a data sync operation.
    
  • nautobot_ssot/templates/nautobot_ssot/history.html+0 14 removed
    @@ -1,14 +0,0 @@
    -{% extends 'generic/object_list.html' %}
    -{% load shorter_timedelta %}
    -
    -{% block header %}
    -    <div class="row">
    -        <div class="col-md-12">
    -            <ol class="breadcrumb">
    -                <li><a href="{% url 'plugins:nautobot_ssot:dashboard' %}">Single Source of Truth</a></li>
    -                <li>Sync History</li>
    -            </ol>
    -        </div>
    -    </div>
    -{% endblock %}
    -{% block title %}SSoT Sync History{% endblock %}
    
  • nautobot_ssot/templates/nautobot_ssot/inc/jobresult_tab.html+10 0 added
    @@ -0,0 +1,10 @@
    +
    +<div class="row">
    +    <div class="col-md-12">
    +        {% include "extras/inc/jobresult.html" with result=object.job_result %}
    +    </div>
    +</div>
    +
    +{% block javascript %}
    +    {% include 'extras/inc/jobresult_js.html' with result=object.job_result %}
    +{% endblock %}
    
  • nautobot_ssot/templates/nautobot_ssot_infoblox/nautobot_ssot_infoblox_config.html+0 12 removed
    @@ -1,12 +0,0 @@
    -{% extends 'nautobot_ssot/config.html' %}
    -{% load helpers %}
    -
    -{% block title %}{{ block.super }} - Infoblox Configs{% endblock %}
    -
    -{% block content %}
    -  <div class="row">
    -    <div class="col-md-8">
    -        {% include 'utilities/obj_table.html' with table=infobloxconfig_table table_template='panel_table.html' heading='Infoblox Configs' %}
    -    </div>
    -  </div>
    -{% endblock content %}
    \ No newline at end of file
    
  • nautobot_ssot/templates/nautobot_ssot_infoblox/ssotinfobloxconfig_changelog.html+0 1 removed
    @@ -1 +0,0 @@
    -{% extends 'generic/object_changelog.html' %}
    
  • nautobot_ssot/templates/nautobot_ssot_infoblox/ssotinfobloxconfig_list.html+0 12 removed
    @@ -1,12 +0,0 @@
    -{% extends 'generic/object_list.html' %}
    -{% load helpers %}
    -{% load buttons %}
    -
    -{% block breadcrumbs %}
    -    <li><a href="{% url 'plugins:nautobot_ssot:config' %}">SSOT Configs</a></li>
    -    <li><a href="{% url 'plugins:nautobot_ssot:ssotinfobloxconfig_list' %}">SSOT Infoblox Configs</a></li>
    -{% endblock breadcrumbs %}
    -
    -{% block buttons %}
    -<a href="{% url 'plugins:nautobot_ssot:config' %}"><button type="button" class="btn btn-primary">SSOT Configs</button></a>
    -{% endblock buttons %}
    
  • nautobot_ssot/templates/nautobot_ssot_infoblox/ssotinfobloxconfig_retrieve.html+0 152 removed
    @@ -1,152 +0,0 @@
    -{% extends 'generic/object_retrieve.html' %}
    -{% load helpers %}
    -{% load buttons %}
    -
    -{% block breadcrumbs %}
    -    <li><a href="{% url 'plugins:nautobot_ssot:config' %}">SSOT Configs</a></li>
    -    <li><a href="{% url 'plugins:nautobot_ssot:ssotinfobloxconfig_list' %}">SSOT Infoblox Configs</a></li>
    -    <li>{{ object|hyperlinked_object }}</li>
    -{% endblock breadcrumbs %}
    -
    -{% block extra_buttons %}
    -<a href="{% url 'plugins:nautobot_ssot:config' %}"><button type="button" class="btn btn-primary">SSOT Configs</button></a>
    -{% endblock extra_buttons %}
    -
    -{% block masthead %}
    -    <h1>
    -        {% block title %}{{ object }}{% endblock title %}
    -    </h1>
    -{% endblock masthead %}
    -
    -{% block content_left_page %}
    -    <div class="panel panel-default">
    -        <div class="panel-heading">
    -            <strong>Infoblox Config</strong>
    -        </div>
    -        <table class="table table-hover panel-body attr-table">
    -            <tr>
    -                <td>Name</td>
    -                <td>{{ object.name }}</td>
    -            </tr>
    -            <tr>
    -                <td>Description</td>
    -                <td>{{ object.description|placeholder }}</td>
    -            </tr>
    -            <tr>
    -                <td>Infoblox Instance</td>
    -                <td>{{ object.infoblox_instance|hyperlinked_object }}</td>
    -            </tr>
    -            <tr>
    -                <td>Default Status for Imported Objects</td>
    -                <td>{{ object.default_status|hyperlinked_object }}</td>
    -            </tr>
    -            <tr>
    -                <td>Infoblox WAPI Version</td>
    -                <td>{{ object.infoblox_wapi_version|placeholder }}</td>
    -            </tr>
    -            <tr>
    -                <td>Can be used in Sync Job</td>
    -                <td>{{ object.job_enabled }}</td>
    -            </tr>
    -            <tr>
    -                <td>Enable Sync from Nautobot to Infoblox</td>
    -                <td>{{ object.enable_sync_to_infoblox }}</td>
    -            </tr>
    -            <tr>
    -                <td>Enable Sync from Infoblox to Nautobot</td>
    -                <td>{{ object.enable_sync_to_nautobot }}</td>
    -            </tr>
    -            <tr>
    -                <td>Import Networks</td>
    -                <td>{{ object.import_subnets }}</td>
    -            </tr>
    -            <tr>
    -                <td>Import IP Addresses</td>
    -                <td>{{ object.import_ip_addresses }}</td>
    -            </tr>
    -            <tr>
    -                <td>Import VLANs</td>
    -                <td>{{ object.import_vlans }}</td>
    -            </tr>
    -            <tr>
    -                <td>Import VLAN Views</td>
    -                <td>{{ object.import_vlan_views }}</td>
    -            </tr>
    -            <tr>
    -                <td>Import IPv4</td>
    -                <td>{{ object.import_ipv4 }}</td>
    -            </tr>
    -            <tr>
    -                <td>Import IPv6</td>
    -                <td>{{ object.import_ipv6 }}</td>
    -            </tr>
    -            <tr>
    -                <td>Infoblox - Fixed IP Address Type</td>
    -                <td>{{ object.fixed_address_type }}</td>
    -            </tr>
    -            <tr>
    -                <td>Infoblox - DNS record type</td>
    -                <td>{{ object.dns_record_type }}</td>
    -            </tr>
    -            <tr>
    -                <td>Infoblox - deletable models</td>
    -                <td>{{ object.infoblox_deletable_models }}</td>
    -            </tr>
    -            <tr>
    -                <td>Nautobot - deletable models</td>
    -                <td>{{ object.nautobot_deletable_models }}</td>
    -            </tr>
    -        </table>
    -    </div>
    -{% endblock %}
    -
    -{% block content_right_page %}
    -    <div class="panel panel-default">
    -        <div class="panel-heading">
    -            <strong>Infoblox Sync Filters</strong>
    -        </div>
    -        <table class="table table-hover panel-body attr-table">
    -            <tr>
    -                <td>
    -                {% include 'extras/inc/json_data.html' with data=object.infoblox_sync_filters format="json" %}
    -                </td>
    -            </tr>
    -        </table>
    -    </div>
    -    <div class="panel panel-default">
    -        <div class="panel-heading">
    -            <strong>Infoblox Network View to Namespace Map</strong>
    -        </div>
    -        <table class="table table-hover panel-body attr-table">
    -            <tr>
    -                <td>
    -                {% include 'extras/inc/json_data.html' with data=object.infoblox_network_view_to_namespace_map format="json" %}
    -                </td>
    -            </tr>
    -        </table>
    -    </div>
    -    <div class="panel panel-default">
    -        <div class="panel-heading">
    -            <strong>Infoblox Network View to DNS View Mapping</strong>
    -        </div>
    -        <table class="table table-hover panel-body attr-table">
    -            <tr>
    -                <td>
    -                {% include 'extras/inc/json_data.html' with data=object.infoblox_dns_view_mapping format="json" %}
    -                </td>
    -            </tr>
    -        </table>
    -    </div>
    -    <div class="panel panel-default">
    -        <div class="panel-heading">
    -            <strong>Extensible Attributes/Custom Fields to Ignore</strong>
    -        </div>
    -        <table class="table table-hover panel-body attr-table">
    -            <tr>
    -                <td>
    -                {% include 'extras/inc/json_data.html' with data=object.cf_fields_ignore format="json" %}
    -                </td>
    -            </tr>
    -        </table>
    -    </div>
    -{% endblock %}
    \ No newline at end of file
    
  • nautobot_ssot/templates/nautobot_ssot/ssot_configs.html+1 0 modified
    @@ -5,6 +5,7 @@
         <div class="row noprint">
             <div class="col-sm-8 col-md-9">
                 <ol class="breadcrumb">
    +                <li><a href="{% url 'plugins:nautobot_ssot:dashboard' %}">Single Source of Truth</a></li>
                     <li><a href="{% url 'plugins:nautobot_ssot:config' %}">SSOT Configs</a></li>
                 </ol>
             </div>
    
  • nautobot_ssot/templates/nautobot_ssot/ssotinfobloxconfig_update.html+0 0 renamed
  • nautobot_ssot/templates/nautobot_ssot/ssotservicenowconfig.html+12 1 renamed
    @@ -1,6 +1,17 @@
    -{% extends "generic/object_edit.html" %}
    +{% extends "generic/object_create.html" %}
     
     {% block header %}
    +    <div class="row noprint">
    +        <div class="col-sm-8 col-md-9">
    +            <ol class="breadcrumb">
    +                <li><a href="{% url 'plugins:nautobot_ssot:dashboard' %}">Single Source of Truth</a></li>
    +                <li><a href="{% url 'plugins:nautobot_ssot:config' %}">SSOT Configs</a></li>
    +                <li>ServiceNow Configuration</li>
    +            </ol>
    +        </div>
    +    </div>
    +    <div class="pull-right noprint">
    +    </div>
         {% if settings.PLUGINS_CONFIG.nautobot_ssot.servicenow_instance or settings.PLUGINS_CONFIG.nautobot_ssot.servicenow_username or settings.PLUGINS_CONFIG.nautobot_ssot.servicenow_password %}
             <div class="alert alert-warning" role="alert">
                 Some configuration of this plugin has been defined in <code>{{ settings.SETTINGS_PATH }}</code>,
    
  • nautobot_ssot/templates/nautobot_ssot/ssotvsphereconfig_update.html+0 0 renamed
  • nautobot_ssot/templates/nautobot_ssot/sync_detail.html+0 198 removed
    @@ -1,198 +0,0 @@
    -{% extends 'nautobot_ssot/sync_header.html' %}
    -{% load buttons %}
    -{% load plugins %}
    -{% load shorter_timedelta %}
    -{% load render_diff %}
    -{% load humanize_bytes %}
    -
    -
    -{% block content %}
    -    <style>
    -        li.diff-added { list-style-type: "+ "; color: #3c763d; background-color: #dff0d8;}
    -        li.diff-subtracted { list-style-type: "- "; color: #a94442; background-color: #f2dede;}
    -        li.diff-changed { list-style-type: "! "; color: #8a6d3b; background-color: #fcf8e3;}
    -        li.diff-unchanged { list-style-type: circle; color: #333; background-color: white;}
    -    </style>
    -    <div class="row">
    -        <div class="col-md-6">
    -            <div class="panel panel-default">
    -                <div class="panel-heading">
    -                    <strong>Sync</strong>
    -                </div>
    -                <table class="table table-hover panel-body attr-table">
    -                    <tr>
    -                        <td>Data Source</td>
    -                        <td>
    -                            {% if object.get_source_url %}
    -                                <a href="{{ object.get_source_url }}">{{ object.source }}</a>
    -                            {% else %}
    -                                {{ object.source }}
    -                            {% endif %}
    -                        </td>
    -                    </tr>
    -                    <tr>
    -                        <td>Data Target</td>
    -                        <td>
    -                            {% if object.get_target_url %}
    -                                <a href="{{ object.get_target_url }}">{{ object.target }}</a>
    -                            {% else %}
    -                                {{ object.target }}
    -                            {% endif %}
    -                        </td>
    -                    </tr>
    -                    <tr>
    -                        <td>Type</td>
    -                        <td>
    -                            {% if object.dry_run %}
    -                                <span class="dry_run label label-default">Dry Run</span>
    -                            {% else %}
    -                                <span class="dry_run label label-info">Sync</span>
    -                            {% endif %}
    -                        </td>
    -                    </tr>
    -                    <tr>
    -                        <td>Start Time</td>
    -                        <td>{{ object.start_time }} <span class="text-muted">({{ object.start_time | timesince }} ago)</span></td>
    -                    </tr>
    -                    <tr>
    -                        <td>End Time</td>
    -                        <td>{{ object.job_result.completed }} <span class="text-muted">({{ object.job_result.completed | timesince }} ago)</span></td>
    -                    </tr>
    -                    <tr>
    -                        <td>Duration</td>
    -                        <td>
    -                            {{ object.duration | shorter_timedelta }} total
    -                            <ul>
    -                                <li>{{ object.source_load_time | shorter_timedelta }} loading from {{ object.source }}</li>
    -                                <li>{{ object.target_load_time | shorter_timedelta }} loading from {{ object.target }}</li>
    -                                <li>{{ object.diff_time | shorter_timedelta }} calculating diffs</li>
    -                                <li>{{ object.sync_time | shorter_timedelta }} performing sync</li>
    -                            </ul>
    -                        </td>
    -                    </tr>
    -                    <tr>
    -                        <td>Status</td>
    -                        <td>{% include 'extras/inc/job_label.html' with result=object.job_result %}</td>
    -                    </tr>
    -                    <tr>
    -                        <td>Job result</td>
    -                        <td><a href="{{ object.job_result.get_absolute_url }}">{{ object.job_result.pk }}</a></td>
    -                    </tr>
    -                </table>
    -            </div>
    -            {% plugin_left_page object %}
    -        </div>
    -        <div class="col-md-6">
    -            <div class="panel panel-default">
    -                <div class="panel-heading">
    -                    <strong>Statistics</strong>
    -                </div>
    -                <table class="table table-hover panel-body attr-table">
    -                    <tr>
    -                        <td>Creates</td>
    -                        <td>
    -                            <a class="label label-success" href="{% url 'plugins:nautobot_ssot:sync_logentries' pk=object.pk %}?action=create">
    -                                {{ object.num_created }}
    -                            </a>
    -                        </td>
    -                    </tr>
    -                    <tr>
    -                        <td>Updates</td>
    -                        <td>
    -                            <a class="label label-warning" href="{% url 'plugins:nautobot_ssot:sync_logentries' pk=object.pk %}?action=update">
    -                                {{ object.num_updated }}
    -                            </a>
    -                        </td>
    -                    </tr>
    -                    <tr>
    -                        <td>Deletes</td>
    -                        <td>
    -                            <a class="label label-danger" href="{% url 'plugins:nautobot_ssot:sync_logentries' pk=object.pk %}?action=delete">
    -                                {{ object.num_deleted }}
    -                            </a>
    -                        </td>
    -                    </tr>
    -                    <tr>
    -                        <td>Failures</td>
    -                        <td>
    -                            <a href="{% url 'plugins:nautobot_ssot:sync_logentries' pk=object.pk %}?status=failure">
    -                                {{ object.num_failed }}
    -                            </a>
    -                        </td>
    -                    </tr>
    -                    <tr>
    -                        <td>Errors</td>
    -                        <td>
    -                            <a href="{% url 'plugins:nautobot_ssot:sync_logentries' pk=object.pk %}?status=error">
    -                                {{ object.num_errored }}
    -                            </a>
    -                        </td>
    -                    </tr>
    -                </table>
    -            </div>
    -            {% if object.source_load_memory_final %}
    -            <div class="panel panel-default">
    -                <div class="panel-heading">
    -                    <strong>Memory Usage Stats</strong>
    -                </div>
    -                <table class="table table-hover panel-body attr-table">
    -                    <tr>
    -                        <td rowspan="2">Memory used loading {{ object.source }}</td>
    -                        <td>Final</td>
    -                        <td>{{ object.source_load_memory_final | humanize_bytes }}</td>
    -                    </tr>
    -                    <tr>
    -                        <td>Peak</td>
    -                        <td>{{ object.source_load_memory_peak | humanize_bytes }}</td>
    -                    </tr>
    -                    <tr>
    -                        <td rowspan="2">Memory used loading {{ object.target }}</td>
    -                        <td>Final</td>
    -                        <td>{{ object.target_load_memory_final | humanize_bytes }}</td>
    -                    </tr>
    -                    <tr>
    -                        <td>Peak</td>
    -                        <td>{{ object.target_load_memory_peak | humanize_bytes }}</td>
    -                    </tr>
    -                    <tr>
    -                        <td rowspan="2">Memory used calculating diff</td>
    -                        <td>Final</td>
    -                        <td>{{ object.diff_memory_final | humanize_bytes }}</td>
    -                    </tr>
    -                    <tr>
    -                        <td>Peak</td>
    -                        <td>{{ object.diff_memory_peak | humanize_bytes }}</td>
    -                    </tr>
    -                    {% if object.sync_memory_final %}
    -                    <tr>
    -                        <td rowspan="2">Memory used syncing</td>
    -                        <td>Final</td>
    -                        <td>{{ object.sync_memory_final | humanize_bytes }}</td>
    -                    </tr>
    -                    <tr>
    -                        <td>Peak</td>
    -                        <td>{{ object.sync_memory_peak | humanize_bytes }}</td>
    -                    </tr>
    -                    {% endif %}
    -                </table>
    -            </div>
    -            {% endif %}
    -            {% include 'inc/custom_fields_panel.html' %}
    -            {% include 'inc/relationships_panel.html' %}
    -            {% plugin_right_page object %}
    -        </div>
    -    </div>
    -    <div class="row">
    -        <div class="col-md-12">
    -            <div class="panel panel-default">
    -                <div class="panel-heading">
    -                    <strong>Diff</strong>
    -                </div>
    -                <div class="panel-body">
    -                    {% render_diff object.diff %}
    -                </div>
    -            </div>
    -        </div>
    -        {% plugin_full_width_page object %}
    -    </div>
    -{% endblock %}
    
  • nautobot_ssot/templates/nautobot_ssot/sync_header.html+0 59 removed
    @@ -1,59 +0,0 @@
    -{% extends 'base.html' %}
    -{% load buttons %}
    -{% load custom_links %}
    -{% load plugins %}
    -
    -{% block header %}
    -    <div class="row">
    -        <div class="col-md-12">
    -            <ol class="breadcrumb">
    -                <li><a href="{% url 'plugins:nautobot_ssot:dashboard' %}">Single Source of Truth</a></li>
    -                {% if object.get_source_url %}
    -                    <li>Data Sources</li>
    -                    <li>
    -                        {% if object.job_result.related_object.class_path %}
    -                            <a href="{% url 'plugins:nautobot_ssot:data_source' class_path=object.job_result.related_object.class_path %}">
    -                                {{ object.job_result.related_object }}
    -                            </a>
    -                        {% else %}
    -                            {{ object.source }}
    -                        {% endif %}
    -                    </li>
    -                {% else %}
    -                    <li>Data Targets</li>
    -                    <li>
    -                        {% if object.job_result.related_object.class_path %}
    -                            <a href="{% url 'plugins:nautobot_ssot:data_target' class_path=object.job_result.related_object.class_path %}">
    -                                {{ object.job_result.related_object }}
    -                            </a>
    -                        {% else %}
    -                            {{ object.target }}
    -                        {% endif %}
    -                    </li>
    -                {% endif %}
    -                <li>{{ object }}</li>
    -            </ol>
    -        </div>
    -    </div>
    -    <div class="pull-right noprint">
    -        {% plugin_buttons object %}
    -        {% if perms.nautobot_ssot.delete_sync %}
    -            {% delete_button object %}
    -        {% endif %}
    -    </div>
    -    <h1>{% block title %}{{ object }}{% endblock %}</h1>
    -    <div class="pull-right noprint">
    -        {% custom_links object %}
    -    </div>
    -    <ul class="nav nav-tabs">
    -        <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
    -            <a href="{{ object.get_absolute_url }}">Data Sync</a>
    -        </li>
    -        <li role="presentation"{% if active_tab == 'jobresult' %} class="active"{% endif %}>
    -            <a href="{% url 'plugins:nautobot_ssot:sync_jobresult' pk=object.pk %}">Job Logs</a>
    -        </li>
    -        <li role="presentation"{% if active_tab == 'logentries' %} class="active"{% endif %}>
    -            <a href="{% url 'plugins:nautobot_ssot:sync_logentries' pk=object.pk %}">Sync Logs</a>
    -        </li>
    -    </ul>
    -{% endblock %}
    
  • nautobot_ssot/templates/nautobot_ssot/sync_jobresult.html+0 13 removed
    @@ -1,13 +0,0 @@
    -{% extends 'nautobot_ssot/sync_header.html' %}
    -
    -{% block content %}
    -    <div class="row">
    -        <div class="col-md-12">
    -            {% include "extras/inc/jobresult.html" with result=object.job_result %}
    -        </div>
    -    </div>
    -{% endblock %}
    -
    -{% block javascript %}
    -    {% include 'extras/inc/jobresult_js.html' with result=object.job_result %}
    -{% endblock %}
    
  • nautobot_ssot/templates/nautobot_ssot/sync_logentries.html+0 11 removed
    @@ -1,11 +0,0 @@
    -{% extends 'nautobot_ssot/sync_header.html' %}
    -{% load buttons %}
    -
    -{% block content %}
    -    <div class="row">
    -        <div class="col-md-12">
    -            {% include "responsive_table.html" %}
    -        </div>
    -        {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
    -    </div>
    -{% endblock %}
    
  • nautobot_ssot/templates/nautobot_ssot/synclogentry_list.html+0 7 removed
    @@ -1,7 +0,0 @@
    -{% extends 'generic/object_list.html' %}
    -
    -{% block extra_breadcrumbs %}
    -                <li><a href="{% url 'plugins:nautobot_ssot:dashboard' %}">Single Source of Truth</a></li>
    -                <li>Sync Logs</li>
    -{% endblock extra_breadcrumbs %}
    -{% block title %}SSoT Sync Logs{% endblock %}
    
  • nautobot_ssot/templates/nautobot_ssot/sync_retrieve.html+11 0 added
    @@ -0,0 +1,11 @@
    +{% extends 'generic/object_retrieve.html' %}
    +{% load helpers %}
    +
    +{% block extra_styles %}
    +    <style>
    +        li.diff-added { list-style-type: "+ "; color: #3c763d; background-color: #dff0d8;}
    +        li.diff-subtracted { list-style-type: "- "; color: #a94442; background-color: #f2dede;}
    +        li.diff-changed { list-style-type: "! "; color: #8a6d3b; background-color: #fcf8e3;}
    +        li.diff-unchanged { list-style-type: circle; color: #333; background-color: white;}
    +    </style>
    +{% endblock extra_styles %}
    \ No newline at end of file
    
  • nautobot_ssot/templates/nautobot_ssot_vsphere/nautobot_ssot_vsphere_config.html+0 12 removed
    @@ -1,12 +0,0 @@
    -{% extends 'nautobot_ssot/config.html' %}
    -{% load helpers %}
    -
    -{% block title %}{{ block.super }} - vSphere Configs{% endblock %}
    -
    -{% block content %}
    -  <div class="row">
    -    <div class="col-md-8">
    -        {% include 'utilities/obj_table.html' with table=vsphereconfig_table table_template='panel_table.html' heading='vSphere Configs' %}
    -    </div>
    -  </div>
    -{% endblock content %}
    
  • nautobot_ssot/templates/nautobot_ssot_vsphere/ssotvsphereconfig_list.html+0 12 removed
    @@ -1,12 +0,0 @@
    -{% extends 'generic/object_list.html' %}
    -{% load helpers %}
    -{% load buttons %}
    -
    -{% block breadcrumbs %}
    -    <li><a href="{% url 'plugins:nautobot_ssot:config' %}">SSOT Configs</a></li>
    -    <li><a href="{% url 'plugins:nautobot_ssot:ssotvsphereconfig_list' %}">SSOT vSphere Configs</a></li>
    -{% endblock breadcrumbs %}
    -
    -{% block buttons %}
    -<a href="{% url 'plugins:nautobot_ssot:config' %}"><button type="button" class="btn btn-primary">SSOT Configs</button></a>
    -{% endblock buttons %}
    \ No newline at end of file
    
  • nautobot_ssot/templates/nautobot_ssot_vsphere/ssotvsphereconfig_retrieve.html+0 108 removed
    @@ -1,108 +0,0 @@
    -{% extends 'generic/object_retrieve.html' %}
    -{% load helpers %}
    -{% load buttons %}
    -
    -{% block breadcrumbs %}
    -    <li><a href="{% url 'plugins:nautobot_ssot:config' %}">SSOT Configs</a></li>
    -    <li><a href="{% url 'plugins:nautobot_ssot:ssotvsphereconfig_list' %}">SSOT vSphere Configs</a></li>
    -    <li>{{ object|hyperlinked_object }}</li>
    -{% endblock breadcrumbs %}
    -
    -{% block extra_buttons %}
    -<a href="{% url 'plugins:nautobot_ssot:config' %}"><button type="button" class="btn btn-primary">SSOT Configs</button></a>
    -{% endblock extra_buttons %}
    -
    -{% block masthead %}
    -    <h1>
    -        {% block title %}{{ object }}{% endblock title %}
    -    </h1>
    -{% endblock masthead %}
    -
    -{% block content_left_page %}
    -    <div class="panel panel-default">
    -        <div class="panel-heading">
    -            <strong>vSphere Config</strong>
    -        </div>
    -        <table class="table table-hover panel-body attr-table">
    -            <tr>
    -                <td>Name</td>
    -                <td>{{ object.name }}</td>
    -            </tr>
    -            <tr>
    -                <td>Description</td>
    -                <td>{{ object.description|placeholder }}</td>
    -            </tr>
    -            <tr>
    -                <td>vSphere Instance</td>
    -                <td>{{ object.vsphere_instance|hyperlinked_object }}</td>
    -            </tr>
    -            <tr>
    -                <td>Default Ignore Link Local</td>
    -                <td>{{ object.default_ignore_link_local }}</td>
    -            </tr>
    -            <tr>
    -                <td>Use Clusters</td>
    -                <td>{{ object.use_clusters }}</td>
    -            </tr>
    -            <tr>
    -                <td>Primary IP Sort By Logic</td>
    -                <td>{{ object.primary_ip_sort_by }}</td>
    -            </tr>
    -            <tr>
    -                <td>Sync Tagged Only</td>
    -                <td>{{ object.sync_tagged_only }}</td>
    -            </tr>
    -            <tr>
    -                <td>Default Cluster Group Name</td>
    -                <td>{{ object.default_clustergroup_name }}</td>
    -            </tr>
    -            <tr>
    -                <td>Default Cluster Name</td>
    -                <td>{{ object.default_cluster_name }}</td>
    -            </tr>
    -            <tr>
    -                <td>Default Cluster Type</td>
    -                <td>{{ object.default_cluster_type }}</td>
    -            </tr>
    -        </table>
    -    </div>
    -{% endblock %}
    -
    -{% block content_right_page %}
    -    <div class="panel panel-default">
    -        <div class="panel-heading">
    -            <strong>vSphere Virtual Machine Status Mappings</strong>
    -        </div>
    -        <table class="table table-hover panel-body attr-table">
    -            <tr>
    -                <td>
    -                {% include 'extras/inc/json_data.html' with data=object.default_vm_status_map format="json" %}
    -                </td>
    -            </tr>
    -        </table>
    -    </div>
    -    <div class="panel panel-default">
    -        <div class="panel-heading">
    -            <strong>vSphere Virtual Machine IP Status Mappings</strong>
    -        </div>
    -        <table class="table table-hover panel-body attr-table">
    -            <tr>
    -                <td>
    -                {% include 'extras/inc/json_data.html' with data=object.default_ip_status_map format="json" %}
    -                </td>
    -            </tr>
    -        </table>
    -    </div>
    -    <div class="panel panel-default">
    -        <div class="panel-heading">
    -            <strong>vSphere Virtual Machine Interface Status Mappings</strong>
    -        </div>
    -        <table class="table table-hover panel-body attr-table">
    -            <tr>
    -                <td>
    -                {% include 'extras/inc/json_data.html' with data=object.default_vm_interface_map format="json" %}
    -                </td>
    -            </tr>
    -        </table>
    -    </div>
    -{% endblock %}
    \ No newline at end of file
    
  • nautobot_ssot/tests/test_views.py+1 26 modified
    @@ -106,13 +106,6 @@ def test_data_source_target_view_with_permission(self):
                 200,
             )
     
    -    def test_has_advanced_tab(self):
    -        pass
    -
    -    @skip("Not implemented")
    -    def test_list_objects_with_permission(self):
    -        pass
    -
     
     class SyncLogEntryViewsTestCase(ViewTestCases.ListObjectsViewTestCase):  # pylint: disable=too-many-ancestors
         """Test views related to the SyncLogEntry model."""
    @@ -147,25 +140,7 @@ def setUpTestData(cls):
                     message="Log message",
                 )
     
    -    def test_has_advanced_tab(self):
    -        pass
    -
    -    @skip("Not implemented")
    -    def test_list_objects_with_permission(self):
    -        pass
    -
    -    @skip("Not implemented")
    -    def test_list_objects_anonymous(self):
    -        pass
    -
    -    @skip("Not implemented")
    -    def test_list_objects_filtered(self):
    -        pass
    -
    +    # This test is skipped because their is no object detail view for the SyncLogEntry model to get a url for.
         @skip("Not implemented")
         def test_list_objects_with_constrained_permission(self):
             pass
    -
    -    @skip("Not implemented")
    -    def test_list_objects_unknown_filter_no_strict_filtering(self):
    -        pass
    
  • nautobot_ssot/urls.py+7 7 modified
    @@ -3,22 +3,20 @@
     from django.templatetags.static import static
     from django.urls import path
     from django.views.generic import RedirectView
    +from nautobot.apps.urls import NautobotUIViewSetRouter
     
     from . import views
     from .integrations.utils import each_enabled_integration_module
     
     app_name = "nautobot_ssot"
    +router = NautobotUIViewSetRouter()
    +router.register("history", views.SyncUIViewSet)
    +router.register("logs", views.SyncLogEntryUIViewSet)
    +
     urlpatterns = [
         path("", views.DashboardView.as_view(), name="dashboard"),
         path("data-sources/<path:class_path>/", views.DataSourceTargetView.as_view(), name="data_source"),
         path("data-targets/<path:class_path>/", views.DataSourceTargetView.as_view(), name="data_target"),
    -    path("history/", views.SyncListView.as_view(), name="sync_list"),
    -    path("history/delete/", views.SyncBulkDeleteView.as_view(), name="sync_bulk_delete"),
    -    path("history/<uuid:pk>/", views.SyncView.as_view(), name="sync"),
    -    path("history/<uuid:pk>/delete/", views.SyncDeleteView.as_view(), name="sync_delete"),
    -    path("history/<uuid:pk>/jobresult/", views.SyncJobResultView.as_view(), name="sync_jobresult"),
    -    path("history/<uuid:pk>/logs/", views.SyncLogEntriesView.as_view(), name="sync_logentries"),
    -    path("logs/", views.SyncLogEntryListView.as_view(), name="synclogentry_list"),
         path("config/", views.SSOTConfigView.as_view(), name="config"),
         path("docs/", RedirectView.as_view(url=static("nautobot_ssot/docs/index.html")), name="docs"),
     ]
    @@ -30,3 +28,5 @@ def _add_integrations():
     
     
     _add_integrations()
    +
    +urlpatterns += router.urls
    
  • nautobot_ssot/views.py+286 93 modified
    @@ -1,26 +1,170 @@
     """Django views for Single Source of Truth (SSoT)."""
     
    -import pprint
    -
    +from django.conf import settings
     from django.http import Http404
    -from django.shortcuts import get_object_or_404, render
    +from django.shortcuts import render
    +from django.template import loader
    +from django.template.defaultfilters import date
    +from django.urls import reverse
    +from django.utils.html import format_html
    +from django.utils.timesince import timesince
     from django.views import View as DjangoView
     from django_tables2 import RequestConfig
    -from nautobot.core.views.generic import BulkDeleteView, ObjectDeleteView, ObjectListView, ObjectView
    -from nautobot.core.views.mixins import ContentTypePermissionRequiredMixin
    -from nautobot.core.views.paginator import EnhancedPaginator
    +from nautobot.apps.ui import (
    +    Breadcrumbs,
    +    DistinctViewTab,
    +    ModelBreadcrumbItem,
    +    ObjectDetailContent,
    +    ObjectFieldsPanel,
    +    ObjectsTablePanel,
    +    ObjectTextPanel,
    +    SectionChoices,
    +    Tab,
    +    ViewNameBreadcrumbItem,
    +    render_component_template,
    +)
    +from nautobot.apps.views import (
    +    ContentTypePermissionRequiredMixin,
    +    EnhancedPaginator,
    +    ObjectBulkDestroyViewMixin,
    +    ObjectDestroyViewMixin,
    +    ObjectDetailViewMixin,
    +    ObjectListView,
    +    ObjectListViewMixin,
    +    ObjectView,
    +    get_obj_from_context,
    +)
    +from nautobot.core.ui.utils import flatten_context
     from nautobot.extras.models import Job as JobModel
    +from rest_framework.decorators import action
    +from rest_framework.response import Response
     
    +from nautobot_ssot.api import serializers
     from nautobot_ssot.integrations import utils
    +from nautobot_ssot.templatetags.render_diff import render_diff
     
     from .filters import SyncFilterSet, SyncLogEntryFilterSet
    -from .forms import SyncFilterForm, SyncLogEntryFilterForm
    +from .forms import SyncBulkEditForm, SyncFilterForm, SyncForm, SyncLogEntryFilterForm
     from .jobs import get_data_jobs
     from .jobs.base import DataSource, DataTarget
     from .models import Sync, SyncLogEntry
     from .tables import DashboardTable, SyncLogEntryTable, SyncTable, SyncTableSingleSourceOrTarget
     
     
    +def dry_run_label(value) -> str:
    +    """Return HTML label for dry run status."""
    +    badge, text = ("default", "Dry Run") if value else ("info", "Sync")
    +    return format_html('<span class="dry_run label label-{}">{}</span>', badge, text)
    +
    +
    +def datetime_with_timesince(value) -> str:
    +    """Return formatted datetime with timesince HTML."""
    +    if not value:
    +        return None
    +    return format_html(
    +        '{} <span class="text-muted">({} ago)</span>',
    +        date(value, settings.DATETIME_FORMAT),
    +        timesince(value),
    +    )
    +
    +
    +class SyncObjectPanel(ObjectFieldsPanel):
    +    """Custom ObjectFieldsPanel to support the rendering of sync duration."""
    +
    +    def render_value(self, key, value, context):
    +        """Render the value for display in the table."""
    +        if key == "duration":
    +            obj = get_obj_from_context(context, self.context_object_key)
    +            return obj.get_duration_display()
    +        # TODO: NEXT-3.0 Replace label label-* with Bootstrap 5 badge classes when Nautobot supports Bootstrap 5
    +        # TODO: If Core adds a different way to render job result status labels, use here:
    +        if key == "job_result__status":
    +            status_labels = {
    +                "FAILURE": ("label label-danger", "Failed"),
    +                "PENDING": ("label label-default", "Pending"),
    +                "STARTED": ("label label-warning", "Running"),
    +                "SUCCESS": ("label label-success", "Completed"),
    +            }
    +            css_class, text = status_labels.get(value, ("label label-default", "N/A"))
    +            return format_html('<label class="{}">{}</label>', css_class, text)
    +        return super().render_value(key, value, context)
    +
    +
    +class StatisticsObjectPanel(ObjectFieldsPanel):
    +    """Custom ObjectFieldsPanel to support the rendering of sync statistics."""
    +
    +    def render_value(self, key, value, context):
    +        """Render the value for display in the table."""
    +        # TODO: NEXT-3.0 Replace label label-* with Bootstrap 5 badge classes when Nautobot supports Bootstrap 5
    +        obj = get_obj_from_context(context, self.context_object_key)
    +        if key == "num_created":
    +            return format_html(
    +                '<a href="{}?action=create" class="label label-success">{}</a>',
    +                reverse("plugins:nautobot_ssot:sync_logentries", kwargs={"pk": obj.pk}),
    +                value,
    +            )
    +        if key == "num_updated":
    +            return format_html(
    +                '<a href="{}?action=update" class="label label-warning">{}</a>',
    +                reverse("plugins:nautobot_ssot:sync_logentries", kwargs={"pk": obj.pk}),
    +                value,
    +            )
    +        if key == "num_deleted":
    +            return format_html(
    +                '<a href="{}?action=delete" class="label label-danger">{}</a>',
    +                reverse("plugins:nautobot_ssot:sync_logentries", kwargs={"pk": obj.pk}),
    +                value,
    +            )
    +        if key == "num_failed":
    +            return format_html(
    +                '<a href="{}?status=failure">{}</a>',
    +                reverse("plugins:nautobot_ssot:sync_logentries", kwargs={"pk": obj.pk}),
    +                value,
    +            )
    +        if key == "num_errored":
    +            return format_html(
    +                '<a href="{}?status=error">{}</a>',
    +                reverse("plugins:nautobot_ssot:sync_logentries", kwargs={"pk": obj.pk}),
    +                value,
    +            )
    +        return super().render_value(key, value, context)
    +
    +
    +class DiffPanel(ObjectTextPanel):
    +    """Custom ObjectTextPanel to support the rendering of sync diffs."""
    +
    +    def get_value(self, context):
    +        """Render the value for the diff."""
    +        obj = get_obj_from_context(context, "object")
    +        return render_diff(obj.diff)
    +
    +
    +class JobResultViewTab(DistinctViewTab):
    +    """View tab for JobResult associated objects."""
    +
    +    def render(self, context):
    +        """Render the tab's contents (layout and panels) to HTML."""
    +        # Check should_render_content first as it's generally a cheaper calculation than should_render checking perms
    +        if not self.should_render_content(context) or not self.should_render(context):
    +            return ""
    +
    +        with context.update(
    +            {
    +                "tab_id": self.tab_id,
    +                "label": self.render_label(context),
    +                "include_plugin_content": self.tab_id == "main",
    +                "left_half_panels": self.panels_for_section(SectionChoices.LEFT_HALF),
    +                "right_half_panels": self.panels_for_section(SectionChoices.RIGHT_HALF),
    +                "full_width_panels": self.panels_for_section(SectionChoices.FULL_WIDTH),
    +                **self.get_extra_context(context),
    +            }
    +        ):
    +            template = loader.get_template("nautobot_ssot/inc/jobresult_tab.html")
    +            # tab_content = render_component_template(self.LAYOUT_TEMPLATE_PATHS[self.layout], context)
    +            tab_content = template.render(flatten_context(context), request=context.get("request"))
    +            return render_component_template(self.content_wrapper_template_path, context, tab_content=tab_content)
    +
    +
     class DashboardView(ObjectListView):
         """Dashboard / overview of SSoT."""
     
    @@ -94,96 +238,145 @@ def get_extra_context(self, request, instance):
             }
     
     
    -class SyncListView(ObjectListView):
    -    """View for listing Sync records."""
    +class SyncUIViewSet(
    +    ObjectDetailViewMixin,
    +    ObjectListViewMixin,
    +    ObjectDestroyViewMixin,
    +    ObjectBulkDestroyViewMixin,
    +):
    +    """ViewSet for Sync."""
     
    +    bulk_update_form_class = SyncBulkEditForm
    +    filterset_class = SyncFilterSet
    +    filterset_form_class = SyncFilterForm
    +    form_class = SyncForm
    +    lookup_field = "pk"
         queryset = Sync.annotated_queryset()
    -    filterset = SyncFilterSet
    -    filterset_form = SyncFilterForm
    -    table = SyncTable
    -    action_buttons = []
    -    template_name = "nautobot_ssot/history.html"
    -
    -    def extra_context(self):
    -        """Extend the view context with additional information."""
    -        data_sources, data_targets = get_data_jobs()
    -        return {
    -            "data_sources": data_sources,
    -            "data_targets": data_targets,
    +    serializer_class = serializers.SyncSerializer
    +    table_class = SyncTable
    +    action_buttons = ("export",)
    +    breadcrumbs = Breadcrumbs(
    +        items={
    +            "list": [
    +                ViewNameBreadcrumbItem(view_name="plugins:nautobot_ssot:dashboard", label="Single Source of Truth"),
    +                ModelBreadcrumbItem(model=Sync),
    +            ],
    +            "detail": [
    +                ViewNameBreadcrumbItem(view_name="plugins:nautobot_ssot:dashboard", label="Single Source of Truth"),
    +                ModelBreadcrumbItem(),
    +            ],
             }
    -
    -
    -class SyncDeleteView(ObjectDeleteView):
    -    """View for deleting a single Sync record."""
    -
    -    queryset = Sync.objects.all()
    -
    -
    -class SyncBulkDeleteView(BulkDeleteView):
    -    """View for bulk-deleting Sync records."""
    -
    -    queryset = Sync.objects.all()
    -    table = SyncTable
    -    filterset = SyncFilterSet
    -
    -
    -class SyncView(ObjectView):
    -    """View for details of a single Sync record."""
    -
    -    queryset = Sync.annotated_queryset()
    -    template_name = "nautobot_ssot/sync_detail.html"
    -
    -    def get_extra_context(self, request, instance):
    -        """Add additional context to the view."""
    -        return {
    -            "diff": pprint.pformat(instance.diff, width=180, compact=True),
    -        }
    -
    -
    -class SyncJobResultView(ObjectView):
    -    """View for the JobResult associated with a single Sync record."""
    -
    -    queryset = Sync.objects.all()
    -    template_name = "nautobot_ssot/sync_jobresult.html"
    -
    -    def get_extra_context(self, request, instance):
    -        """Add additional context to the view."""
    -        return {
    -            "active_tab": "jobresult",
    -        }
    -
    -
    -class SyncLogEntriesView(ObjectListView):
    -    """View for SyncLogEntries associated with a given Sync."""
    -
    -    queryset = SyncLogEntry.objects.all()
    -    filterset = SyncLogEntryFilterSet
    -    filterset_form = SyncLogEntryFilterForm
    -    table = SyncLogEntryTable
    -    action_buttons = []
    -    template_name = "nautobot_ssot/sync_logentries.html"
    -
    -    def get(self, request, pk):  # pylint: disable=arguments-differ
    -        """HTTP GET request handler."""
    -        self.instance = get_object_or_404(Sync.objects.all(), pk=pk)  # pylint: disable=attribute-defined-outside-init
    -        self.queryset = SyncLogEntry.objects.filter(sync=self.instance)
    -
    -        return super().get(request)
    -
    -    def extra_context(self):
    -        """Add additional context to the view."""
    -        return {"active_tab": "logentries", "object": self.instance}
    -
    -
    -class SyncLogEntryListView(ObjectListView):
    -    """View for listing SyncLogEntry records."""
    -
    +    )
    +
    +    object_detail_content = ObjectDetailContent(
    +        panels=(
    +            SyncObjectPanel(
    +                weight=100,
    +                section=SectionChoices.LEFT_HALF,
    +                fields=[
    +                    "source",
    +                    "target",
    +                    "dry_run",
    +                    "start_time",
    +                    "end_time",
    +                    "duration",
    +                    "job_result__status",
    +                    "job_result",
    +                ],
    +                value_transforms={
    +                    "dry_run": [dry_run_label],
    +                    "start_time": [datetime_with_timesince],
    +                    "end_time": [datetime_with_timesince],
    +                },
    +            ),
    +            StatisticsObjectPanel(
    +                weight=200,
    +                section=SectionChoices.RIGHT_HALF,
    +                label="Statistics",
    +                fields=[
    +                    "num_created",
    +                    "num_updated",
    +                    "num_deleted",
    +                    "num_failed",
    +                    "num_errored",
    +                ],
    +                key_transforms={
    +                    "num_created": "creates",
    +                    "num_updated": "updates",
    +                    "num_deleted": "deletes",
    +                    "num_failed": "failures",
    +                    "num_errored": "errors",
    +                },
    +            ),
    +            DiffPanel(
    +                weight=300,
    +                section=SectionChoices.FULL_WIDTH,
    +                label="Diff",
    +                object_field="diff",
    +                render_as=ObjectTextPanel.RenderOptions.PLAINTEXT,
    +                render_placeholder=True,
    +            ),
    +        ),
    +        extra_tabs=(
    +            DistinctViewTab(
    +                weight=Tab.WEIGHT_CHANGELOG_TAB + 200,
    +                tab_id="logentries",
    +                label="Sync Logs",
    +                url_name="plugins:nautobot_ssot:sync_logentries",
    +                hide_if_empty=False,
    +                related_object_attribute="logs",
    +                panels=(
    +                    ObjectsTablePanel(
    +                        weight=100,
    +                        section=SectionChoices.FULL_WIDTH,
    +                        table_class=SyncLogEntryTable,
    +                        table_filter="sync",
    +                        related_field_name="sync",
    +                        tab_id="logentries",
    +                        enable_bulk_actions=False,
    +                        include_paginator=True,
    +                    ),
    +                ),
    +            ),
    +            JobResultViewTab(
    +                weight=Tab.WEIGHT_CHANGELOG_TAB + 100,
    +                tab_id="jobresult",
    +                label="Job Logs",
    +                url_name="plugins:nautobot_ssot:sync_jobresult",
    +                hide_if_empty=False,
    +            ),
    +        ),
    +    )
    +
    +    @action(detail=True, url_path="logs", custom_view_base_action="view")
    +    def logentries(self, request, *args, **kwargs):
    +        """Log entries action for Sync UIViewSet."""
    +        return Response({})
    +
    +    @action(detail=True, url_path="jobresult", custom_view_base_action="view")
    +    def jobresult(self, request, *args, **kwargs):
    +        """Job result action for Sync UIViewSet."""
    +        return Response({})
    +
    +
    +class SyncLogEntryUIViewSet(ObjectListViewMixin):
    +    """ViewSet for SyncLogEntry."""
    +
    +    filterset_class = SyncLogEntryFilterSet
    +    filterset_form_class = SyncLogEntryFilterForm
    +    lookup_field = "pk"
         queryset = SyncLogEntry.objects.all()
    -    filterset = SyncLogEntryFilterSet
    -    filterset_form = SyncLogEntryFilterForm
    -    table = SyncLogEntryTable
    -    action_buttons = []
    -    template_name = "nautobot_ssot/synclogentry_list.html"
    +    serializer_class = serializers.SyncLogEntrySerializer
    +    table_class = SyncLogEntryTable
    +    action_buttons = ("export",)
    +    breadcrumbs = Breadcrumbs(
    +        items={
    +            "list": [
    +                ViewNameBreadcrumbItem(view_name="plugins:nautobot_ssot:dashboard", label="Single Source of Truth"),
    +                ModelBreadcrumbItem(model=SyncLogEntry),
    +            ],
    +        }
    +    )
     
     
     class SSOTConfigView(ContentTypePermissionRequiredMixin, DjangoView):
    
  • poetry.lock+806 1263 modified
  • pyproject.toml+6 3 modified
    @@ -13,7 +13,6 @@ classifiers = [
         "Intended Audience :: Developers",
         "Development Status :: 5 - Production/Stable",
         "Programming Language :: Python :: 3",
    -    "Programming Language :: Python :: 3.9",
         "Programming Language :: Python :: 3.10",
         "Programming Language :: Python :: 3.11",
         "Programming Language :: Python :: 3.12",
    @@ -27,9 +26,9 @@ include = [
     ]
     
     [tool.poetry.dependencies]
    -python = ">=3.9.2,<3.13"
    +python = ">=3.10,<3.13"
     # Used for local development
    -nautobot = ">=2.4.2,<3.0.0"
    +nautobot = ">=2.4.20,<3.0.0"
     diffsync = "^2.0.0"
     Jinja2 = { version = ">=2.11.3", optional = true }
     Markdown = "!=3.3.5"
    @@ -203,6 +202,7 @@ no-docstring-rgx = "^(_|test_|Meta$)"
     
     [tool.pylint.messages_control]
     disable = """,
    +    abstract-method,
         line-too-long,
         too-few-public-methods,
         too-many-positional-arguments,
    @@ -218,6 +218,9 @@ notes = """,
         XXX,
         """
     
    +[tool.pylint.similarities]
    +min-similarity-lines = 7
    +
     [tool.pylint-nautobot]
     supported_nautobot_versions = [
         "2"
    
  • tasks.py+1 1 modified
    @@ -52,7 +52,7 @@ def is_truthy(arg):
     namespace.configure(
         {
             "nautobot_ssot": {
    -            "nautobot_ver": "2.4.2",
    +            "nautobot_ver": "2.4.20",
                 "project_name": "nautobot-ssot",
                 "python_ver": "3.11",
                 "local": False,
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.