Initial commit

This commit is contained in:
sven committed 2026-08-16 13:10:26 +02:00
commit 4ff9eac274
45 files changed
+2911

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
from netbox.plugins import PluginConfig
__version__ = "0.1.0"
class NetBoxServicesConfig(PluginConfig):
name = "netbox_services"
verbose_name = "NetBox Services"
description = "Declarative service templates with requirement trees."
version = __version__
author = "Sven Luethi"
author_email = "dev@sven.luethi.co"
base_url = "services"
min_version = "4.6.0"
def ready(self):
super().ready()
from . import signals # noqa: F401
config = NetBoxServicesConfig
View File
Whitespace-only changes.
+36
View File
@@ -0,0 +1,36 @@
from rest_framework import serializers
from netbox.api.serializers import NetBoxModelSerializer
from ..models import Service, ServiceTemplate
class ServiceTemplateSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(
view_name="plugins-api:netbox_services-api:servicetemplate-detail",
)
class Meta:
model = ServiceTemplate
fields = (
"id", "url", "display", "name", "status",
"tree", "description", "comments", "tags",
"created", "last_updated",
)
brief_fields = ("id", "url", "display", "name", "status")
class ServiceSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(
view_name="plugins-api:netbox_services-api:service-detail",
)
service_template = ServiceTemplateSerializer(nested=True)
class Meta:
model = Service
fields = (
"id", "url", "display", "name", "service_template",
"tenant", "status", "description", "comments", "tags",
"created", "last_updated",
)
brief_fields = ("id", "url", "display", "name", "status")
+9
View File
@@ -0,0 +1,9 @@
from netbox.api.routers import NetBoxRouter
from .views import ServiceTemplateViewSet, ServiceViewSet
router = NetBoxRouter()
router.register("service-templates", ServiceTemplateViewSet)
router.register("services", ServiceViewSet)
urlpatterns = router.urls
+62
View File
@@ -0,0 +1,62 @@
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.response import Response
from netbox.api.viewsets import NetBoxModelViewSet
from ..engine.resolver import Resolver
from ..models import Service, ServiceTemplate
from .serializers import ServiceSerializer, ServiceTemplateSerializer
class ServiceTemplateViewSet(NetBoxModelViewSet):
queryset = ServiceTemplate.objects.all()
serializer_class = ServiceTemplateSerializer
class ServiceViewSet(NetBoxModelViewSet):
queryset = Service.objects.all()
serializer_class = ServiceSerializer
@action(detail=True, methods=["post"])
def resolve(self, request, pk=None):
"""Resolve a service's requirement tree.
Request body: ``{"mode": "dry_run" | "commit"}``
"""
service = self.get_object()
mode = request.data.get("mode", "dry_run")
resolver = Resolver()
try:
result = resolver.resolve(service, mode=mode, user=request.user)
except Exception as exc:
return Response(
{"error": str(exc)},
status=status.HTTP_400_BAD_REQUEST,
)
planned_data = []
for node_name, obj in result.planned_objects:
ct = obj.__class__._meta
planned_data.append({
"node": node_name,
"object_type": f"{ct.app_label}.{ct.model_name}",
"object_id": obj.pk,
"object": str(obj),
})
response_data = {
"mode": mode,
"succeeded": result.succeeded,
"total_planned": result.total_planned,
"total_current": result.total_current,
"to_add": len(result.to_add),
"to_remove": len(result.to_remove),
"planned": planned_data,
"errors": result.errors,
"warnings": result.warnings,
"summary": result.summary,
}
return Response(response_data)
View File
Whitespace-only changes.
+78
View File
@@ -0,0 +1,78 @@
"""Allocators create or acquire objects that don't exist yet (e.g. IP addresses)."""
from .registry import register_allocator, get_selector
@register_allocator("first_free")
class FirstFreeIPAddressAllocator:
"""Allocate the first available IP address from a resolved prefix.
Spec shape::
{"strategy": "first_free", "prefix": {"type": "reference", "path": "parent.site.mgmt_prefix"}}
{"strategy": "first_free", "prefix": {"type": "prefix", "filters": {"site": "hq"}}}
"""
def __init__(self, spec):
self.spec = spec
self.prefix_spec = spec.get("prefix", {})
def resolve_prefix(self, context):
from ipam.models import Prefix
selector = get_selector(self.prefix_spec)
qs = selector.resolve(context, Prefix)
return qs.first()
def allocate(self, context):
from ipam.models import IPAddress
from ipam.choices import IPAddressStatusChoices
prefix = self.resolve_prefix(context)
if prefix is None:
raise ValueError("No prefix resolved for first_free allocation.")
available = prefix.get_first_available_ip()
if available is None:
raise ValueError(
f"No available IP addresses in prefix {prefix.prefix}."
)
ip = IPAddress.objects.create(
address=available,
status=IPAddressStatusChoices.STATUS_ACTIVE,
)
return ip
@register_allocator("specific")
class SpecificAllocator:
"""Use a specific object identified by content type + ID or context path.
Spec shape (by reference)::
{"strategy": "specific", "path": "parent.site.primary_ip4"}
Spec shape (by value, for IP addresses)::
{"strategy": "specific", "address": "10.0.0.1/24"}
"""
def __init__(self, spec):
self.spec = spec
self.path = spec.get("path")
self.address = spec.get("address")
def allocate(self, context):
if self.address:
from ipam.models import IPAddress
from ipam.choices import IPAddressStatusChoices
ip, _ = IPAddress.objects.get_or_create(
address=self.address,
defaults={"status": IPAddressStatusChoices.STATUS_ACTIVE},
)
return ip
if self.path:
return context.resolve_path(self.path)
raise ValueError("SpecificAllocator requires 'path' or 'address'.")
+52
View File
@@ -0,0 +1,52 @@
"""Resolution context — carries service and parent references during tree walk."""
class Context:
"""Evaluation frame for the resolver.
Top-level context has ``service`` set and ``parent_object`` as None.
Each selected/allocated object pushes a child context so children
can reference ``parent`` and traverse its relations.
"""
def __init__(self, service, parent_object=None):
self.service = service
self.parent_object = parent_object
def child(self, obj):
return Context(
service=self.service,
parent_object=obj,
)
@property
def parent(self):
return self.parent_object
def resolve_path(self, path):
"""Resolve a dotted reference like ``parent.site.management_prefix``.
Supported roots:
- ``service`` → the Service instance
- ``parent`` → the parent object (or None at service scope)
Returns None if any segment is None or missing.
"""
parts = path.split(".")
root = parts[0]
if root == "service":
obj = self.service
elif root == "parent":
obj = self.parent_object
else:
raise KeyError(f"Unknown context root: {root!r}")
for part in parts[1:]:
if obj is None:
return None
if isinstance(obj, dict):
obj = obj.get(part)
else:
obj = getattr(obj, part, None)
return obj
+64
View File
@@ -0,0 +1,64 @@
"""Registry for selectors and allocators — pluggable extension points."""
_selectors = {}
_allocators = {}
class RegistryError(Exception):
pass
class SelectorNotFoundError(RegistryError):
pass
class AllocatorNotFoundError(RegistryError):
pass
def register_selector(type_name):
def decorator(cls):
_selectors[type_name] = cls
cls.type = type_name
return cls
return decorator
def get_selector(spec):
if not spec or not isinstance(spec, dict):
raise ValueError(f"Invalid selector spec: {spec!r}")
type_name = spec.get("type")
if type_name not in _selectors:
raise SelectorNotFoundError(
f"No selector registered for type {type_name!r}. "
f"Available: {list(_selectors.keys())}"
)
return _selectors[type_name](spec)
def register_allocator(strategy_name):
def decorator(cls):
_allocators[strategy_name] = cls
cls.strategy = strategy_name
return cls
return decorator
def get_allocator(spec):
if not spec or not isinstance(spec, dict):
raise ValueError(f"Invalid allocation spec: {spec!r}")
strategy = spec.get("strategy")
if strategy not in _allocators:
raise AllocatorNotFoundError(
f"No allocator registered for strategy {strategy!r}. "
f"Available: {list(_allocators.keys())}"
)
return _allocators[strategy](spec)
def registered_selectors():
return dict(_selectors)
def registered_allocators():
return dict(_allocators)
+245
View File
@@ -0,0 +1,245 @@
"""The resolver walks a service template's requirement tree (stored as JSON)
and reconciles the service's bindings against real NetBox objects.
No intermediate PlannedBinding objects — the resolver works with plain
sets of (content_type_id, object_id) tuples for reconciliation and a
flat list of resolved objects for dry-run preview.
"""
import logging
from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from ..models import (
ResolutionModeChoices,
ServiceResourceBinding,
)
from .context import Context
from .registry import get_allocator, get_selector
logger = logging.getLogger("netbox_services.resolver")
class ResolutionError(Exception):
pass
class ConflictError(ResolutionError):
"""An object is already bound to another service."""
def __init__(self, obj, existing_service):
self.obj = obj
self.existing_service = existing_service
super().__init__(
f"Object {obj!r} (pk={obj.pk}) is already bound to service "
f"'{existing_service}'."
)
class Resolver:
"""Walks the template's JSON tree and reconciles bindings."""
def resolve(self, service, mode="dry_run", user=None):
if mode not in (ResolutionModeChoices.MODE_DRY_RUN, ResolutionModeChoices.MODE_COMMIT):
raise ValueError(f"Invalid mode: {mode!r}")
tree = service.service_template.tree
result = ResolutionResult(mode=mode, service=service)
try:
with transaction.atomic():
self._resolve_tree(tree, service, result)
result.compute_diff()
if mode == ResolutionModeChoices.MODE_DRY_RUN:
transaction.set_rollback(True)
else:
for ct_id, obj_id in result.to_add:
ServiceResourceBinding.objects.create(
service=service,
content_type_id=ct_id,
object_id=obj_id,
)
for binding in result.to_remove_bindings:
binding.delete()
except Exception as exc:
result.errors.append(str(exc))
logger.exception("Resolution failed for service %s", service)
raise
return result
def _resolve_tree(self, tree, service, result):
"""Walk the JSON tree and populate result.planned_set + result.planned_objects."""
context = Context(service=service)
for node in tree:
self._resolve_node(node, service, result, context)
def _resolve_node(self, node, service, result, context):
"""Resolve a single tree node, then recurse into children."""
object_type_str = node["object_type"]
app_label, model_name = object_type_str.split(".")
ot = ContentType.objects.get_by_natural_key(app_label, model_name)
model = ot.model_class()
if model is None:
raise ResolutionError(
f"Node '{node['name']}': object_type '{object_type_str}' has no model class."
)
quantity = node.get("quantity", 1)
selector_spec = node.get("selector")
allocation_spec = node.get("allocation")
objects = self._resolve_objects(
node, service, result, context, model, quantity, selector_spec, allocation_spec,
)
for obj in objects:
ct = ContentType.objects.get_for_model(obj.__class__)
key = (ct.id, obj.pk)
result.planned_set.add(key)
result.planned_objects.append((node["name"], obj))
self._check_conflict(obj, service)
child_context = context.child(obj)
children = node.get("children", [])
for child_node in children:
scope = child_node.get("scope", "parent")
if scope == "parent" or len(objects) <= 1:
self._resolve_node(child_node, service, result, child_context)
elif scope == "service":
self._resolve_node(child_node, service, result, context)
def _resolve_objects(self, node, service, result, context, model, quantity, selector_spec, allocation_spec):
"""Return a list of resolved objects for this node."""
if allocation_spec:
return self._allocate_with_reuse(
node, service, result, context, model, quantity, allocation_spec,
)
if selector_spec:
return self._select(node, service, result, context, model, quantity)
raise ResolutionError(
f"Node '{node['name']}': must have either 'selector' or 'allocation'."
)
def _select(self, node, service, result, context, model, quantity):
"""Select existing objects via the selector registry."""
selector = get_selector(node["selector"])
qs = selector.resolve(context, model)
bound_ids = self._get_bound_ids(model, service)
if bound_ids:
qs = qs.exclude(pk__in=bound_ids)
objects = list(qs[:quantity])
result.summary["selected"] = result.summary.get("selected", 0) + len(objects)
if len(objects) < quantity:
result.warnings.append(
f"Node '{node['name']}': only {len(objects)} of {quantity} objects available."
)
return objects
def _allocate_with_reuse(self, node, service, result, context, model, quantity, allocation_spec):
"""Allocate new objects, but reuse existing bindings of the same type if present."""
existing = self._get_existing_bound_objects(model, service)
if len(existing) >= quantity:
objects = existing[:quantity]
result.summary["reused"] = result.summary.get("reused", 0) + len(objects)
return objects
objects = list(existing)
needed = quantity - len(existing)
allocator = get_allocator(allocation_spec)
for _ in range(needed):
obj = allocator.allocate(context)
if obj is None:
raise ResolutionError(
f"Node '{node['name']}': allocator returned None."
)
objects.append(obj)
result.summary["allocated"] = result.summary.get("allocated", 0) + len(objects) - len(existing)
return objects
def _get_bound_ids(self, model, service):
"""Return PKs of model instances already bound to other services."""
ct = ContentType.objects.get_for_model(model)
qs = (
ServiceResourceBinding.objects
.filter(content_type=ct)
.exclude(service=service)
)
return set(qs.values_list("object_id", flat=True))
def _get_existing_bound_objects(self, model, service):
"""Return model instances already bound to this service."""
ct = ContentType.objects.get_for_model(model)
binding_ids = (
ServiceResourceBinding.objects
.filter(service=service, content_type=ct)
.values_list("object_id", flat=True)
)
return list(model.objects.filter(pk__in=binding_ids))
def _check_conflict(self, obj, service):
"""Raise if obj is already bound to another service."""
ct = ContentType.objects.get_for_model(obj.__class__)
if (
ServiceResourceBinding.objects
.filter(content_type=ct, object_id=obj.pk)
.exclude(service=service)
.exists()
):
existing = (
ServiceResourceBinding.objects
.filter(content_type=ct, object_id=obj.pk)
.exclude(service=service)
.first()
)
raise ConflictError(obj, existing.service.name)
class ResolutionResult:
def __init__(self, mode, service):
self.mode = mode
self.service = service
self.planned_set = set()
self.planned_objects = []
self.errors = []
self.warnings = []
self.summary = {}
self.to_add = set()
self.to_remove = set()
self.to_remove_bindings = []
def compute_diff(self):
"""Compare planned_set against current bindings."""
current_qs = ServiceResourceBinding.objects.filter(service=self.service)
current_set = set()
current_map = {}
for binding in current_qs:
key = (binding.content_type_id, binding.object_id)
current_set.add(key)
current_map[key] = binding
self.to_add = self.planned_set - current_set
self.to_remove = current_set - self.planned_set
self.to_remove_bindings = [current_map[k] for k in self.to_remove]
@property
def succeeded(self):
return len(self.errors) == 0
@property
def total_planned(self):
return len(self.planned_set)
@property
def total_current(self):
return ServiceResourceBinding.objects.filter(service=self.service).count()
+104
View File
@@ -0,0 +1,104 @@
"""Selectors resolve a specification into a queryset of matching NetBox objects."""
import importlib
from django.db.models import Q
from .registry import register_selector
def _get_filterset_for_model(model):
"""Look up a model's FilterSet by convention: {app_label}.filtersets.{ModelName}FilterSet."""
app_label = model._meta.app_label
model_name = model._meta.model_name
class_name = f"{model.__name__}FilterSet"
try:
module = importlib.import_module(f"{app_label}.filtersets")
return getattr(module, class_name, None)
except (ImportError, AttributeError):
return None
@register_selector("netbox_filter")
class NetBoxFilterSelector:
"""Select objects by applying NetBox filter parameters.
Spec shape::
{"type": "netbox_filter", "filters": {"role": "router", "site__in": ["hq"]}}
"""
def __init__(self, spec):
self.spec = spec
self.filters = spec.get("filters", {})
def resolve(self, context, model):
qs = model.objects.all()
filterset_cls = _get_filterset_for_model(model)
if filterset_cls is not None:
fs = filterset_cls(self.filters, queryset=qs, request=None)
qs = fs.qs
else:
qs = qs.filter(**self._build_q(self.filters))
return qs
@staticmethod
def _build_q(filters):
"""Best-effort: turn the filter dict into ORM kwargs (no fancy lookups)."""
return {k: v for k, v in filters.items()}
@register_selector("reference")
class ReferenceSelector:
"""Select a single object via a dotted context path.
Spec shape::
{"type": "reference", "path": "parent.site"}
"""
def __init__(self, spec):
self.spec = spec
self.path = spec.get("path")
def resolve(self, context, model):
obj = context.resolve_path(self.path)
if obj is None:
return model.objects.none()
if isinstance(obj, (list, tuple)):
return model.objects.filter(pk__in=[o.pk for o in obj])
return model.objects.filter(pk=obj.pk)
@register_selector("prefix")
class PrefixSelector:
"""Select ipam.Prefix objects by filter or context reference.
Spec shape (filter)::
{"type": "prefix", "filters": {"site": "hq", "tag": "mgmt"}}
Spec shape (reference)::
{"type": "prefix", "path": "parent.site.management_prefix"}
"""
def __init__(self, spec):
self.spec = spec
self.filters = spec.get("filters")
self.path = spec.get("path")
def resolve(self, context, model):
from ipam.models import Prefix
if self.path:
obj = context.resolve_path(self.path)
if obj is None:
return Prefix.objects.none()
return Prefix.objects.filter(pk=obj.pk)
if self.filters is not None:
filterset_cls = _get_filterset_for_model(Prefix)
qs = Prefix.objects.all()
if filterset_cls is not None:
fs = filterset_cls(self.filters, queryset=qs, request=None)
return fs.qs
return qs.filter(**self.filters)
return Prefix.objects.none()
+42
View File
@@ -0,0 +1,42 @@
import django_filters
from django.db.models import Q
from netbox.filtersets import NetBoxModelFilterSet
from utilities.filtersets import register_filterset
from .models import Service, ServiceTemplate
@register_filterset
class ServiceTemplateFilterSet(NetBoxModelFilterSet):
class Meta:
model = ServiceTemplate
fields = ("id", "name", "status")
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value)
| Q(description__icontains=value),
)
@register_filterset
class ServiceFilterSet(NetBoxModelFilterSet):
service_template = django_filters.ModelMultipleChoiceFilter(
field_name="service_template",
queryset=ServiceTemplate.objects.all(),
)
class Meta:
model = Service
fields = ("id", "name", "status", "service_template", "tenant")
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value)
| Q(description__icontains=value),
)
+197
View File
@@ -0,0 +1,197 @@
import json
import yaml
from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from netbox.forms import (
NetBoxModelBulkEditForm,
NetBoxModelFilterSetForm,
NetBoxModelForm,
NetBoxModelImportForm,
PrimaryModelForm,
)
from tenancy.models import Tenant
from utilities.forms.fields import (
DynamicModelChoiceField,
DynamicModelMultipleChoiceField,
TagFilterField,
)
from utilities.forms.rendering import FieldSet
from .models import (
Service,
ServiceStatusChoices,
ServiceTemplate,
ServiceTemplateStatusChoices,
validate_tree,
)
# ── Service Template ─────────────────────────────────────────────────
class ServiceTemplateForm(PrimaryModelForm):
tree_raw = forms.CharField(
label=_("Requirement tree"),
required=False,
widget=forms.Textarea(
attrs={
"rows": 20,
"class": "font-monospace",
"placeholder": (
'- name: routers\n'
' object_type: dcim.device\n'
' quantity: 2\n'
' selector:\n'
' type: netbox_filter\n'
' filters:\n'
' role: router\n'
' children:\n'
' - name: mgmt-ip\n'
' object_type: ipam.ipaddress\n'
' allocation:\n'
' strategy: first_free\n'
' prefix:\n'
' type: prefix\n'
' filters: {}\n'
),
},
),
help_text=_("Enter the requirement tree as YAML or JSON."),
)
fieldsets = (
FieldSet("name", "status", "tree_raw", name=_("Service Template")),
FieldSet("description", "tags", name=_("Metadata")),
)
class Meta:
model = ServiceTemplate
fields = ("name", "status", "description", "comments")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance and self.instance.pk and self.instance.tree:
self.fields["tree_raw"].initial = yaml.dump(
self.instance.tree, default_flow_style=False, sort_keys=False,
)
def clean(self):
super().clean()
raw = self.cleaned_data.get("tree_raw", "").strip()
if not raw:
self.instance.tree = []
return self.cleaned_data
tree = None
try:
tree = json.loads(raw)
except (json.JSONDecodeError, ValueError):
pass
if tree is None:
try:
tree = yaml.safe_load(raw)
except yaml.YAMLError as exc:
self.add_error("tree_raw", _(f"Invalid YAML or JSON: {exc}"))
return self.cleaned_data
if tree is None:
tree = []
self.instance.tree = tree
self.instance._tree_validated_by_form = True
try:
validate_tree(tree)
except ValidationError as exc:
self.add_error("tree_raw", ValidationError(exc.messages))
return self.cleaned_data
class ServiceTemplateFilterSetForm(NetBoxModelFilterSetForm):
model = ServiceTemplate
name = forms.CharField(required=False)
status = forms.MultipleChoiceField(
choices=ServiceTemplateStatusChoices,
required=False,
)
tag = TagFilterField(model)
class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm):
model = ServiceTemplate
status = forms.ChoiceField(
choices=ServiceTemplateStatusChoices,
required=False,
)
description = forms.CharField(required=False)
nullable_fields = ("description",)
class ServiceTemplateImportForm(NetBoxModelImportForm):
class Meta:
model = ServiceTemplate
fields = ("name", "status", "description", "comments", "tags")
# ── Service ──────────────────────────────────────────────────────────
class ServiceForm(PrimaryModelForm):
service_template = DynamicModelChoiceField(
queryset=ServiceTemplate.objects.all(),
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
)
fieldsets = (
FieldSet("name", "service_template", "tenant", "status", "description", "tags", name=_("Service")),
)
class Meta:
model = Service
fields = ("name", "service_template", "tenant", "status", "description", "comments")
class ServiceFilterSetForm(NetBoxModelFilterSetForm):
model = Service
name = forms.CharField(required=False)
service_template = DynamicModelMultipleChoiceField(
queryset=ServiceTemplate.objects.all(),
required=False,
)
tenant = DynamicModelMultipleChoiceField(
queryset=Tenant.objects.all(),
required=False,
)
status = forms.MultipleChoiceField(
choices=ServiceStatusChoices,
required=False,
)
tag = TagFilterField(model)
class ServiceBulkEditForm(NetBoxModelBulkEditForm):
model = Service
status = forms.ChoiceField(
choices=ServiceStatusChoices,
required=False,
)
description = forms.CharField(required=False)
nullable_fields = ("description",)
# ── Resolve form ─────────────────────────────────────────────────────
class ResolveForm(forms.Form):
mode = forms.ChoiceField(
choices=(
("dry_run", _("Dry Run")),
("commit", _("Commit")),
),
initial="dry_run",
widget=forms.RadioSelect,
)
@@ -0,0 +1,87 @@
# Generated by Django 6.0.7 on 2026-08-15 20:08
import django.db.models.deletion
import netbox.models.deletion
import taggit.managers
import utilities.json
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('extras', '0140_imageattachment_image_size'),
('tenancy', '0024_default_ordering_indexes'),
('users', '0016_default_ordering_indexes'),
]
operations = [
migrations.CreateModel(
name='ServiceTemplate',
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)),
('status', models.CharField(default='draft', max_length=20)),
('tree', models.JSONField(blank=True, default=list)),
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='netbox_services_templates', to='users.owner')),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
'verbose_name': 'service template',
'verbose_name_plural': 'service templates',
'ordering': ('name',),
},
bases=(netbox.models.deletion.DeleteMixin, models.Model),
),
migrations.CreateModel(
name='Service',
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)),
('status', models.CharField(default='planned', max_length=20)),
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='netbox_services_services', to='users.owner')),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='netbox_services_services', to='tenancy.tenant')),
('service_template', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='services', to='netbox_services.servicetemplate')),
],
options={
'verbose_name': 'service',
'verbose_name_plural': 'services',
'ordering': ('name',),
},
bases=(netbox.models.deletion.DeleteMixin, models.Model),
),
migrations.CreateModel(
name='ServiceResourceBinding',
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)),
('object_id', models.PositiveBigIntegerField()),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')),
('service', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bindings', to='netbox_services.service')),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
'verbose_name': 'service resource binding',
'verbose_name_plural': 'service resource bindings',
'ordering': ('service', 'content_type', 'object_id'),
'indexes': [models.Index(fields=['content_type', 'object_id'], name='netbox_serv_content_c47db1_idx')],
},
bases=(netbox.models.deletion.DeleteMixin, models.Model),
),
]
Whitespace-only changes.
+255
View File
@@ -0,0 +1,255 @@
import json
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from core.models import ObjectType
from netbox.models import NetBoxModel, PrimaryModel
from utilities.choices import ChoiceSet
class ServiceTemplateStatusChoices(ChoiceSet):
key = "ServiceTemplate.status"
STATUS_DRAFT = "draft"
STATUS_ACTIVE = "active"
STATUS_DISCONTINUED = "discontinued"
CHOICES = [
(STATUS_DRAFT, _("Draft"), "cyan"),
(STATUS_ACTIVE, _("Active"), "green"),
(STATUS_DISCONTINUED, _("Discontinued"), "gray"),
]
class ServiceStatusChoices(ChoiceSet):
key = "Service.status"
STATUS_PLANNED = "planned"
STATUS_ACTIVE = "active"
STATUS_DISCONTINUED = "discontinued"
CHOICES = [
(STATUS_PLANNED, _("Planned"), "cyan"),
(STATUS_ACTIVE, _("Active"), "green"),
(STATUS_DISCONTINUED, _("Discontinued"), "gray"),
]
class RequirementScopeChoices(ChoiceSet):
key = "ServiceRequirement.scope"
SCOPE_SERVICE = "service"
SCOPE_PARENT = "parent"
CHOICES = [
(SCOPE_SERVICE, _("Service"), "blue"),
(SCOPE_PARENT, _("Parent"), "green"),
]
class ResolutionModeChoices(ChoiceSet):
key = "Resolution.mode"
MODE_DRY_RUN = "dry_run"
MODE_COMMIT = "commit"
CHOICES = [
(MODE_DRY_RUN, _("Dry Run"), "cyan"),
(MODE_COMMIT, _("Commit"), "green"),
]
def validate_tree(tree):
"""Validate the declarative requirement tree structure."""
if not isinstance(tree, list):
raise ValidationError({"tree": _("Tree must be a list of requirement nodes.")})
seen_names = set()
def _validate_node(node, path="root"):
if not isinstance(node, dict):
raise ValidationError({"tree": _(f"Node at {path} must be a dict.")})
name = node.get("name")
if not name:
raise ValidationError({"tree": _(f"Node at {path} must have a 'name'.")})
node_path = f"{path}/{name}"
if name in seen_names:
raise ValidationError({"tree": _(f"Duplicate node name '{name}'.")})
seen_names.add(name)
object_type = node.get("object_type")
if not object_type or not isinstance(object_type, str):
raise ValidationError({"tree": _(f"Node '{name}' must have an 'object_type' string (e.g. 'dcim.device').")})
parts = object_type.split(".")
if len(parts) != 2:
raise ValidationError({"tree": _(f"Node '{name}': object_type must be 'app_label.model_name'.")})
try:
ot = ObjectType.objects.get_by_natural_key(parts[0], parts[1])
except ObjectType.DoesNotExist:
raise ValidationError({"tree": _(f"Node '{name}': object_type '{object_type}' not found.")})
if ot.model_class() is None:
raise ValidationError({"tree": _(f"Node '{name}': object_type '{object_type}' has no model class.")})
quantity = node.get("quantity", 1)
if not isinstance(quantity, int) or quantity < 1:
raise ValidationError({"tree": _(f"Node '{name}': quantity must be a positive integer.")})
selector = node.get("selector")
allocation = node.get("allocation")
if not selector and not allocation:
raise ValidationError({"tree": _(f"Node '{name}': must have either 'selector' or 'allocation'.")})
if selector and not isinstance(selector, dict):
raise ValidationError({"tree": _(f"Node '{name}': selector must be a dict.")})
if allocation and not isinstance(allocation, dict):
raise ValidationError({"tree": _(f"Node '{name}': allocation must be a dict.")})
scope = node.get("scope", "parent")
if scope not in RequirementScopeChoices.values():
raise ValidationError({"tree": _(f"Node '{name}': scope must be 'service' or 'parent'.")})
children = node.get("children", [])
if not isinstance(children, list):
raise ValidationError({"tree": _(f"Node '{name}': children must be a list.")})
for i, child in enumerate(children):
_validate_node(child, node_path)
for i, node in enumerate(tree):
_validate_node(node, f"root[{i}]")
class ServiceTemplate(PrimaryModel):
name = models.CharField(
verbose_name=_("name"),
max_length=100,
unique=True,
)
status = models.CharField(
verbose_name=_("status"),
max_length=20,
choices=ServiceTemplateStatusChoices,
default=ServiceTemplateStatusChoices.STATUS_DRAFT,
)
tree = models.JSONField(
verbose_name=_("requirement tree"),
default=list,
blank=True,
)
owner = models.ForeignKey(
to="users.Owner",
on_delete=models.PROTECT,
blank=True,
null=True,
related_name="netbox_services_templates",
)
clone_fields = ("description", "comments", "tree")
class Meta:
ordering = ("name",)
verbose_name = _("service template")
verbose_name_plural = _("service templates")
def __str__(self):
return self.name
def get_absolute_url(self):
from utilities.views import get_viewname
return reverse(get_viewname(self), args=[self.pk])
@property
def is_locked(self):
return self.services.exists()
def clean(self):
super().clean()
if not getattr(self, "_tree_validated_by_form", False):
try:
validate_tree(self.tree)
except ValidationError as e:
raise ValidationError(e.messages)
class Service(PrimaryModel):
service_template = models.ForeignKey(
to=ServiceTemplate,
on_delete=models.PROTECT,
related_name="services",
)
name = models.CharField(
verbose_name=_("name"),
max_length=100,
unique=True,
)
tenant = models.ForeignKey(
to="tenancy.Tenant",
on_delete=models.PROTECT,
related_name="netbox_services_services",
verbose_name=_("tenant"),
)
status = models.CharField(
verbose_name=_("status"),
max_length=20,
choices=ServiceStatusChoices,
default=ServiceStatusChoices.STATUS_PLANNED,
)
owner = models.ForeignKey(
to="users.Owner",
on_delete=models.PROTECT,
blank=True,
null=True,
related_name="netbox_services_services",
)
clone_fields = ("service_template", "tenant", "description", "comments")
class Meta:
ordering = ("name",)
verbose_name = _("service")
verbose_name_plural = _("services")
def __str__(self):
return self.name
def get_absolute_url(self):
from utilities.views import get_viewname
return reverse(get_viewname(self), args=[self.pk])
class ServiceResourceBinding(NetBoxModel):
_netbox_private = True
service = models.ForeignKey(
to=Service,
on_delete=models.CASCADE,
related_name="bindings",
)
content_type = models.ForeignKey(
to=ContentType,
on_delete=models.PROTECT,
related_name="+",
)
object_id = models.PositiveBigIntegerField()
content_object = GenericForeignKey("content_type", "object_id")
class Meta:
ordering = ("service", "content_type", "object_id")
verbose_name = _("service resource binding")
verbose_name_plural = _("service resource bindings")
indexes = [
models.Index(fields=["content_type", "object_id"]),
]
def __str__(self):
obj = self.content_object
label = str(obj) if obj is not None else f"{self.content_type} #{self.object_id}"
return f"{self.service} → {label}"
+24
View File
@@ -0,0 +1,24 @@
from django.utils.translation import gettext_lazy as _
from netbox.plugins import PluginMenu, PluginMenuItem
templates_item = PluginMenuItem(
link="plugins:netbox_services:servicetemplate_list",
link_text=_("Service Templates"),
permissions=["netbox_services.view_servicetemplate"],
buttons=(),
)
services_item = PluginMenuItem(
link="plugins:netbox_services:service_list",
link_text=_("Services"),
permissions=["netbox_services.view_service"],
buttons=(),
)
menu = PluginMenu(
label=_("Services"),
groups=(
(_("Services"), (templates_item, services_item)),
),
icon_class="mdi mdi-file-tree",
)
+23
View File
@@ -0,0 +1,23 @@
from netbox.search import SearchIndex, register_search
from .models import Service, ServiceTemplate
@register_search
class ServiceTemplateSearchIndex(SearchIndex):
model = ServiceTemplate
fields = (
("name", 100),
("description", 500),
)
display_attrs = ("status",)
@register_search
class ServiceSearchIndex(SearchIndex):
model = Service
fields = (
("name", 100),
("description", 500),
)
display_attrs = ("service_template", "status", "tenant")
+37
View File
@@ -0,0 +1,37 @@
from django.db.models.signals import pre_delete, pre_save
from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _
from .models import Service, ServiceTemplate
class TemplateLockedError(Exception):
"""Raised when attempting to modify a locked template."""
def __init__(self, obj, message=None):
self.obj = obj
self.message = message or _(
"Cannot modify {}: it is locked because one or more services "
"have been spawned from it."
).format(obj)
super().__init__(self.message)
@receiver(pre_save, sender=ServiceTemplate)
def lock_service_template_save(sender, instance, **kwargs):
if not instance.pk:
return
if instance.services.exists():
try:
old = ServiceTemplate.objects.get(pk=instance.pk)
except ServiceTemplate.DoesNotExist:
return
for field in ("name", "tree"):
if getattr(instance, field) != getattr(old, field):
raise TemplateLockedError(instance)
@receiver(pre_delete, sender=ServiceTemplate)
def lock_service_template_delete(sender, instance, **kwargs):
if instance.services.exists():
raise TemplateLockedError(instance)
+39
View File
@@ -0,0 +1,39 @@
import django_tables2 as tables
from django.utils.translation import gettext_lazy as _
from netbox.tables import NetBoxTable
from netbox.tables.columns import (
ChoiceFieldColumn,
TagColumn,
)
from .models import Service, ServiceTemplate
class ServiceTemplateTable(NetBoxTable):
name = tables.Column(linkify=True)
status = ChoiceFieldColumn()
services_count = tables.Column(
verbose_name=_("Services"),
accessor="services__count",
orderable=False,
)
tags = TagColumn()
class Meta(NetBoxTable.Meta):
model = ServiceTemplate
fields = ("pk", "id", "name", "status", "description", "services_count", "tags")
default_columns = ("pk", "name", "status", "description", "services_count")
class ServiceTable(NetBoxTable):
name = tables.Column(linkify=True)
service_template = tables.Column(linkify=True)
tenant = tables.Column(linkify=True)
status = ChoiceFieldColumn()
tags = TagColumn()
class Meta(NetBoxTable.Meta):
model = Service
fields = ("pk", "id", "name", "service_template", "tenant", "status", "description", "tags")
default_columns = ("pk", "name", "service_template", "tenant", "status", "description")
@@ -0,0 +1,29 @@
{% load helpers %}
{% load i18n %}
{% if nodes %}
<ul class="tree" style="list-style: none; padding-left: {{ depth|add:'20' }}px;">
{% for node in nodes %}
<li>
<strong>{{ node.name }}</strong>
— <code>{{ node.object_type }}</code>
<span class="badge bg-secondary">{{ node.quantity|default:1 }}×</span>
<span class="badge bg-info">{{ node.scope|default:'parent' }}</span>
{% if node.selector %}
<details class="d-inline">
<summary class="badge bg-primary">{% trans "selector" %}</summary>
<pre class="small mt-1">{{ node.selector|json }}</pre>
</details>
{% endif %}
{% if node.allocation %}
<details class="d-inline">
<summary class="badge bg-success">{% trans "allocation" %}</summary>
<pre class="small mt-1">{{ node.allocation|json }}</pre>
</details>
{% endif %}
{% if node.children %}
{% include 'netbox_services/_tree_node.html' with nodes=node.children depth=depth|add:'1' %}
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
@@ -0,0 +1,80 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load i18n %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-7">
<div class="card">
<h5 class="card-header">{% trans "Service" %}</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Service Template" %}</th>
<td><a href="{{ object.service_template.get_absolute_url }}">{{ object.service_template }}</a></td>
</tr>
<tr>
<th scope="row">{% trans "Tenant" %}</th>
<td>{{ object.tenant|default:'—' }}</td>
</tr>
<tr>
<th scope="row">{% trans "Status" %}</th>
<td>{{ object.get_status_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
</table>
</div>
</div>
</div>
<div class="col col-md-5">
<div class="card">
<h5 class="card-header">{% trans "Actions" %}</h5>
<div class="card-body">
<a href="{% url 'plugins:netbox_services:service_resolve' pk=object.pk %}" class="btn btn-primary mb-2">
<i class="mdi mdi-play"></i> {% trans "Resolve" %}
</a>
<p class="text-muted small mb-0">
{% trans "Run the resolver to preview and/or reconcile the service's resource bindings." %}
</p>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">{% trans "Resource Bindings" %}</h5>
<div class="card-body">
{% if bindings %}
<table class="table table-sm table-hover">
<thead>
<tr>
<th>{% trans "Object Type" %}</th>
<th>{% trans "Object" %}</th>
</tr>
</thead>
<tbody>
{% for binding in bindings %}
<tr>
<td>{{ binding.content_type }}</td>
<td>{{ binding.content_object|default:'—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<span class="text-muted">{% trans "No bindings. Resolve the service to create bindings." %}</span>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,119 @@
{% extends 'base/layout.html' %}
{% load helpers %}
{% load i18n %}
{% block title %}{{ service }} — {% trans "Resolve" %}{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">
{% trans "Resolution Preview" %} — {{ service }}
</h5>
<div class="card-body">
{% if result.errors %}
<div class="alert alert-danger">
<h6>{% trans "Errors" %}</h6>
<ul>
{% for error in result.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if result.warnings %}
<div class="alert alert-warning">
<h6>{% trans "Warnings" %}</h6>
<ul>
{% for warning in result.warnings %}
<li>{{ warning }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<div class="row mb-3">
<div class="col col-md-4">
<div class="card border-success">
<div class="card-body text-center">
<h3 class="text-success">{{ result.to_add|length }}</h3>
<p class="mb-0">{% trans "to add" %}</p>
</div>
</div>
</div>
<div class="col col-md-4">
<div class="card border-danger">
<div class="card-body text-center">
<h3 class="text-danger">{{ result.to_remove|length }}</h3>
<p class="mb-0">{% trans "to remove" %}</p>
</div>
</div>
</div>
<div class="col col-md-4">
<div class="card border-secondary">
<div class="card-body text-center">
<h3 class="text-secondary">{{ result.total_planned|add:'0' }}</h3>
<p class="mb-0">{% trans "total planned" %}</p>
</div>
</div>
</div>
</div>
{% if planned_preview %}
<table class="table table-sm table-hover">
<thead>
<tr>
<th>{% trans "Node" %}</th>
<th>{% trans "Object" %}</th>
<th>{% trans "Action" %}</th>
</tr>
</thead>
<tbody>
{% for item in planned_preview %}
<tr>
<td>{{ item.node }}</td>
<td>{{ item.object }}</td>
<td>
{% if item.action == "add" %}
<span class="badge bg-success">{% trans "add" %}</span>
{% else %}
<span class="badge bg-secondary">{% trans "keep" %}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted">{% trans "No objects would be bound." %}</p>
{% endif %}
</div>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">{% trans "Commit" %}</h5>
<div class="card-body">
<p class="text-muted">
{% trans "Committing will add/remove bindings. Allocated objects (e.g. IP addresses) are not deleted on removal — only the binding is removed." %}
</p>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn btn-success">
<i class="mdi mdi-check"></i> {% trans "Commit" %}
</button>
<a href="{% url 'plugins:netbox_services:service' pk=service.pk %}" class="btn btn-secondary">
{% trans "Cancel" %}
</a>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,67 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load i18n %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-7">
<div class="card">
<h5 class="card-header">{% trans "Service Template" %}</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Status" %}</th>
<td>{{ object.get_status_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Locked" %}</th>
<td>
{% if is_locked %}
<span class="text-warning">{% trans "Yes — services have been spawned" %}</span>
{% else %}
<span class="text-success">{% trans "No — editable" %}</span>
{% endif %}
</td>
</tr>
</table>
</div>
</div>
</div>
<div class="col col-md-5">
<div class="card">
<h5 class="card-header">{% trans "Stats" %}</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Services" %}</th>
<td><a href="{% url 'plugins:netbox_services:service_list' %}?service_template={{ object.pk }}">{{ object.services.count }}</a></td>
</tr>
</table>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">{% trans "Requirement Tree" %}</h5>
<div class="card-body">
{% if object.tree %}
{% include 'netbox_services/_tree_node.html' with nodes=object.tree depth=0 %}
{% else %}
<span class="text-muted">{% trans "No requirements defined." %}</span>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
View File
Whitespace-only changes.
+171
View File
@@ -0,0 +1,171 @@
import pytest
from netbox_services.models import Service, ServiceResourceBinding, ServiceTemplate
@pytest.fixture
def tenant(db):
from tenancy.models import Tenant
return Tenant.objects.create(name="test-tenant", slug="test-tenant")
@pytest.fixture
def service_template(db):
return ServiceTemplate.objects.create(
name="test-template",
status="draft",
description="A test service template",
tree=[
{
"name": "device",
"object_type": "dcim.device",
"quantity": 1,
"selector": {"type": "netbox_filter", "filters": {}},
"scope": "service",
"children": [],
}
],
)
@pytest.fixture
def service(db, service_template, tenant):
return Service.objects.create(
name="test-service",
service_template=service_template,
tenant=tenant,
status="planned",
)
@pytest.fixture
def admin_user(db):
from users.models import User
return User.objects.create_superuser("test-admin", "admin@test.local", "test")
@pytest.fixture
def admin_client(db, admin_user):
from django.test import Client
client = Client()
client.force_login(admin_user)
return client
@pytest.fixture
def api_client(db, admin_user):
from rest_framework.test import APIClient
client = APIClient()
client.force_authenticate(user=admin_user)
return client
@pytest.mark.django_db
class TestServiceTemplate:
def test_str(self, service_template):
assert str(service_template) == "test-template"
def test_default_status(self, db):
st = ServiceTemplate.objects.create(name="default-status")
assert st.status == "draft"
def test_default_tree(self, db):
st = ServiceTemplate.objects.create(name="default-tree")
assert st.tree == []
def test_is_locked_false(self, service_template):
assert service_template.is_locked is False
def test_is_locked_true(self, service_template, service):
assert service_template.is_locked is True
def test_ordering(self, db):
ServiceTemplate.objects.create(name="z-last")
ServiceTemplate.objects.create(name="a-first")
names = list(ServiceTemplate.objects.values_list("name", flat=True))
assert names == ["a-first", "z-last"]
def test_tree_validation_valid(self, db):
st = ServiceTemplate(
name="valid-tree",
tree=[{
"name": "dev",
"object_type": "dcim.device",
"quantity": 1,
"selector": {"type": "netbox_filter", "filters": {}},
"scope": "service",
"children": [],
}],
)
st.clean()
def test_tree_validation_missing_name(self, db):
from django.core.exceptions import ValidationError
st = ServiceTemplate(
name="bad-tree",
tree=[{"object_type": "dcim.device", "quantity": 1, "selector": {"type": "netbox_filter", "filters": {}}}],
)
with pytest.raises(ValidationError):
st.clean()
def test_tree_validation_missing_object_type(self, db):
from django.core.exceptions import ValidationError
st = ServiceTemplate(
name="bad-tree2",
tree=[{"name": "dev", "quantity": 1, "selector": {"type": "netbox_filter", "filters": {}}}],
)
with pytest.raises(ValidationError):
st.clean()
def test_tree_validation_no_selector_or_allocation(self, db):
from django.core.exceptions import ValidationError
st = ServiceTemplate(
name="bad-tree3",
tree=[{"name": "dev", "object_type": "dcim.device", "quantity": 1}],
)
with pytest.raises(ValidationError):
st.clean()
@pytest.mark.django_db
class TestService:
def test_str(self, service):
assert str(service) == "test-service"
def test_default_status(self, db, service_template, tenant):
s = Service.objects.create(
name="status-test", service_template=service_template, tenant=tenant,
)
assert s.status == "planned"
def test_tenant_required(self, db, service_template):
from django.db import IntegrityError
with pytest.raises(IntegrityError):
Service.objects.create(
name="no-tenant", service_template=service_template,
)
def test_protect_on_template_delete(self, service, service_template):
from django.db.models import ProtectedError
with pytest.raises(ProtectedError):
service_template.delete()
@pytest.mark.django_db
class TestServiceResourceBinding:
def test_str(self, service):
from dcim.models import Site
from django.contrib.contenttypes.models import ContentType
site = Site.objects.create(name="bind-test-site", slug="bind-test-site")
ct = ContentType.objects.get_for_model(Site)
binding = ServiceResourceBinding.objects.create(
service=service,
content_type=ct,
object_id=site.pk,
)
assert "test-service" in str(binding)
assert "bind-test-site" in str(binding)
def test_private(self):
assert ServiceResourceBinding._netbox_private is True
+43
View File
@@ -0,0 +1,43 @@
import pytest
from netbox_services.engine.allocators import FirstFreeIPAddressAllocator, SpecificAllocator
@pytest.mark.django_db
class TestFirstFreeIPAddressAllocator:
def test_allocate_creates_ip(self, db):
from ipam.models import IPAddress, Prefix
from netbox_services.engine.context import Context
Prefix.objects.create(prefix="10.99.0.0/24")
allocator = FirstFreeIPAddressAllocator({
"strategy": "first_free",
"prefix": {"type": "prefix", "filters": {}},
})
ip = allocator.allocate(Context(service=None))
assert ip is not None
assert "10.99.0." in str(ip.address)
def test_no_prefix_raises(self, db):
from netbox_services.engine.context import Context
allocator = FirstFreeIPAddressAllocator({
"strategy": "first_free",
"prefix": {"type": "prefix", "filters": {"description": "no-such-prefix"}},
})
with pytest.raises(ValueError):
allocator.allocate(Context(service=None))
@pytest.mark.django_db
class TestSpecificAllocator:
def test_specific_address(self, db):
from netbox_services.engine.context import Context
allocator = SpecificAllocator({"strategy": "specific", "address": "10.50.0.1/24"})
ip = allocator.allocate(Context(service=None))
assert str(ip.address) == "10.50.0.1/24"
def test_specific_requires_address_or_path(self, db):
from netbox_services.engine.context import Context
allocator = SpecificAllocator({"strategy": "specific"})
with pytest.raises(ValueError):
allocator.allocate(Context(service=None))
+111
View File
@@ -0,0 +1,111 @@
import pytest
from netbox_services.models import Service, ServiceTemplate
@pytest.mark.django_db
class TestServiceTemplateAPI:
def test_list(self, api_client):
ServiceTemplate.objects.create(name="api-tmpl")
resp = api_client.get("/api/plugins/services/service-templates/")
assert resp.status_code == 200
assert resp.data["count"] >= 1
def test_create_with_tree(self, api_client):
resp = api_client.post("/api/plugins/services/service-templates/", {
"name": "api-created-tmpl",
"status": "draft",
"tree": [{"name": "dev", "object_type": "dcim.device", "quantity": 1, "selector": {"type": "netbox_filter", "filters": {}}, "scope": "service", "children": []}],
}, format="json")
assert resp.status_code == 201
assert resp.data["name"] == "api-created-tmpl"
assert len(resp.data["tree"]) == 1
def test_retrieve(self, api_client):
st = ServiceTemplate.objects.create(name="api-retrieve-tmpl")
resp = api_client.get(f"/api/plugins/services/service-templates/{st.pk}/")
assert resp.status_code == 200
def test_delete(self, api_client):
st = ServiceTemplate.objects.create(name="api-delete-tmpl")
resp = api_client.delete(f"/api/plugins/services/service-templates/{st.pk}/")
assert resp.status_code == 204
@pytest.mark.django_db
class TestServiceAPI:
def test_list(self, api_client, service_template, tenant):
Service.objects.create(name="api-svc", service_template=service_template, tenant=tenant)
resp = api_client.get("/api/plugins/services/services/")
assert resp.status_code == 200
assert resp.data["count"] >= 1
def test_create(self, api_client, service_template, tenant):
resp = api_client.post("/api/plugins/services/services/", {
"name": "api-created-svc",
"service_template": service_template.pk,
"tenant": tenant.pk,
"status": "planned",
}, format="json")
assert resp.status_code == 201
def test_resolve_dry_run(self, api_client, db, tenant):
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
site = Site.objects.create(name="api-res-site", slug="api-res-site")
mfr = Manufacturer.objects.create(name="api-res-mfr", slug="api-res-mfr")
dt = DeviceType.objects.create(manufacturer=mfr, model="api-model", slug="api-model")
role = DeviceRole.objects.create(name="api-role", slug="api-role")
Device.objects.create(name="api-dev", site=site, device_type=dt, role=role)
tmpl = ServiceTemplate.objects.create(
name="api-res-tmpl",
status="active",
tree=[{
"name": "device",
"object_type": "dcim.device",
"quantity": 1,
"selector": {"type": "netbox_filter", "filters": {"role": ["api-role"]}},
"scope": "service",
"children": [],
}],
)
svc = Service.objects.create(name="api-res-svc", service_template=tmpl, tenant=tenant)
resp = api_client.post(f"/api/plugins/services/services/{svc.pk}/resolve/", {
"mode": "dry_run",
}, format="json")
assert resp.status_code == 200
assert resp.data["mode"] == "dry_run"
assert resp.data["total_planned"] == 1
def test_resolve_commit(self, api_client, db, tenant):
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
from netbox_services.models import ServiceResourceBinding
site = Site.objects.create(name="api-commit-site", slug="api-commit-site")
mfr = Manufacturer.objects.create(name="api-commit-mfr", slug="api-commit-mfr")
dt = DeviceType.objects.create(manufacturer=mfr, model="api-commit-model", slug="api-commit-model")
role = DeviceRole.objects.create(name="api-commit-role", slug="api-commit-role")
Device.objects.create(name="api-commit-dev", site=site, device_type=dt, role=role)
tmpl = ServiceTemplate.objects.create(
name="api-commit-tmpl",
status="active",
tree=[{
"name": "device",
"object_type": "dcim.device",
"quantity": 1,
"selector": {"type": "netbox_filter", "filters": {"role": ["api-commit-role"]}},
"scope": "service",
"children": [],
}],
)
svc = Service.objects.create(name="api-commit-svc", service_template=tmpl, tenant=tenant)
resp = api_client.post(f"/api/plugins/services/services/{svc.pk}/resolve/", {
"mode": "commit",
}, format="json")
assert resp.status_code == 200
assert resp.data["mode"] == "commit"
assert ServiceResourceBinding.objects.filter(service=svc).count() == 1
+46
View File
@@ -0,0 +1,46 @@
import pytest
from netbox_services.models import Service, ServiceTemplate
from netbox_services.signals import TemplateLockedError
@pytest.fixture
def template_with_service(db, tenant):
st = ServiceTemplate.objects.create(name="locked-tmpl", status="active", tree=[])
Service.objects.create(name="locked-svc", service_template=st, tenant=tenant)
return st
@pytest.fixture
def tenant(db):
from tenancy.models import Tenant
return Tenant.objects.create(name="lock-tenant", slug="lock-tenant")
@pytest.mark.django_db
class TestTemplateLock:
def test_cannot_edit_name_when_locked(self, template_with_service):
template_with_service.name = "renamed"
with pytest.raises(TemplateLockedError):
template_with_service.save()
def test_cannot_edit_tree_when_locked(self, template_with_service):
template_with_service.tree = [{"name": "x", "object_type": "dcim.device", "quantity": 1, "selector": {"type": "netbox_filter", "filters": {}}}]
with pytest.raises(TemplateLockedError):
template_with_service.save()
def test_cannot_delete_when_locked(self, template_with_service):
from django.db.models import ProtectedError
with pytest.raises((ProtectedError, TemplateLockedError)):
template_with_service.delete()
def test_can_edit_when_unlocked(self, db):
st = ServiceTemplate.objects.create(name="unlocked", status="draft", tree=[])
st.name = "renamed-unlocked"
st.save()
assert ServiceTemplate.objects.get(pk=st.pk).name == "renamed-unlocked"
def test_can_delete_when_unlocked(self, db):
st = ServiceTemplate.objects.create(name="deletable", tree=[])
st.delete()
assert not ServiceTemplate.objects.filter(pk=st.pk).exists()
+244
View File
@@ -0,0 +1,244 @@
import pytest
from netbox_services.engine.resolver import Resolver, ConflictError
from netbox_services.models import Service, ServiceResourceBinding, ServiceTemplate
@pytest.fixture
def site(db):
from dcim.models import Site
return Site.objects.create(name="resolver-site", slug="resolver-site")
@pytest.fixture
def device_type(db):
from dcim.models import DeviceType, Manufacturer
mfr = Manufacturer.objects.create(name="resolver-mfr", slug="resolver-mfr")
return DeviceType.objects.create(manufacturer=mfr, model="resolver-model", slug="resolver-model")
@pytest.fixture
def role(db):
from dcim.models import DeviceRole
return DeviceRole.objects.create(name="resolver-role", slug="resolver-role")
@pytest.fixture
def devices(site, device_type, role, db):
from dcim.models import Device
return [
Device.objects.create(name="dev-a", site=site, device_type=device_type, role=role),
Device.objects.create(name="dev-b", site=site, device_type=device_type, role=role),
]
@pytest.fixture
def prefix(db):
from ipam.models import Prefix
return Prefix.objects.create(prefix="10.200.0.0/24")
@pytest.fixture
def tenant(db):
from tenancy.models import Tenant
return Tenant.objects.create(name="res-tenant", slug="res-tenant")
@pytest.fixture
def service_with_devices(db, devices, tenant):
tmpl = ServiceTemplate.objects.create(
name="dev-tmpl",
status="active",
tree=[{
"name": "routers",
"object_type": "dcim.device",
"quantity": 2,
"selector": {"type": "netbox_filter", "filters": {"role": ["resolver-role"]}},
"scope": "service",
"children": [],
}],
)
return Service.objects.create(name="dev-svc", service_template=tmpl, tenant=tenant)
@pytest.fixture
def service_with_ip_alloc(db, prefix, tenant):
tmpl = ServiceTemplate.objects.create(
name="ip-tmpl",
status="active",
tree=[{
"name": "ip",
"object_type": "ipam.ipaddress",
"quantity": 1,
"allocation": {
"strategy": "first_free",
"prefix": {"type": "prefix", "filters": {}},
},
"scope": "service",
"children": [],
}],
)
return Service.objects.create(name="ip-svc", service_template=tmpl, tenant=tenant)
@pytest.mark.django_db
class TestResolverDryRun:
def test_dry_run_no_bindings(self, service_with_devices):
resolver = Resolver()
result = resolver.resolve(service_with_devices, mode="dry_run")
assert result.total_planned == 2
assert ServiceResourceBinding.objects.filter(service=service_with_devices).count() == 0
def test_dry_run_rolls_back_allocation(self, service_with_ip_alloc):
from ipam.models import IPAddress
resolver = Resolver()
result = resolver.resolve(service_with_ip_alloc, mode="dry_run")
assert result.total_planned == 1
assert IPAddress.objects.count() == 0
assert ServiceResourceBinding.objects.count() == 0
@pytest.mark.django_db
class TestResolverCommit:
def test_commit_creates_bindings(self, service_with_devices):
resolver = Resolver()
result = resolver.resolve(service_with_devices, mode="commit")
assert result.succeeded
assert ServiceResourceBinding.objects.filter(service=service_with_devices).count() == 2
def test_commit_allocates_ip(self, service_with_ip_alloc):
from ipam.models import IPAddress
resolver = Resolver()
result = resolver.resolve(service_with_ip_alloc, mode="commit")
assert IPAddress.objects.count() == 1
assert ServiceResourceBinding.objects.filter(service=service_with_ip_alloc).count() == 1
def test_commit_nested(self, db, devices, prefix, tenant):
from ipam.models import IPAddress
from dcim.models import Interface
for dev in devices:
Interface.objects.create(device=dev, name="eth0")
tmpl = ServiceTemplate.objects.create(
name="nested-tmpl",
status="active",
tree=[{
"name": "routers",
"object_type": "dcim.device",
"quantity": 1,
"selector": {"type": "netbox_filter", "filters": {"role": ["resolver-role"]}},
"scope": "service",
"children": [{
"name": "ip",
"object_type": "ipam.ipaddress",
"quantity": 1,
"allocation": {
"strategy": "first_free",
"prefix": {"type": "prefix", "filters": {}},
},
"scope": "parent",
"children": [],
}],
}],
)
svc = Service.objects.create(name="nested-svc", service_template=tmpl, tenant=tenant)
resolver = Resolver()
result = resolver.resolve(svc, mode="commit")
assert result.succeeded
assert ServiceResourceBinding.objects.filter(service=svc).count() == 2
@pytest.mark.django_db
class TestReconciliation:
def test_reresolve_no_change(self, service_with_devices):
resolver = Resolver()
resolver.resolve(service_with_devices, mode="commit")
result = resolver.resolve(service_with_devices, mode="commit")
assert len(result.to_add) == 0
assert len(result.to_remove) == 0
assert ServiceResourceBinding.objects.filter(service=service_with_devices).count() == 2
def test_reresolve_reuses_allocated(self, service_with_ip_alloc):
from ipam.models import IPAddress
resolver = Resolver()
resolver.resolve(service_with_ip_alloc, mode="commit")
ip_count_before = IPAddress.objects.count()
result = resolver.resolve(service_with_ip_alloc, mode="commit")
assert len(result.to_add) == 0
assert IPAddress.objects.count() == ip_count_before
def test_reresolve_removes_stale(self, service_with_devices, devices):
resolver = Resolver()
result = resolver.resolve(service_with_devices, mode="commit")
assert ServiceResourceBinding.objects.filter(service=service_with_devices).count() == 2
svc_tmpl = service_with_devices.service_template
svc_tmpl.tree[0]["quantity"] = 1
# Template is locked — bypass via queryset
ServiceTemplate.objects.filter(pk=svc_tmpl.pk).update(tree=svc_tmpl.tree)
service_with_devices.refresh_from_db()
service_with_devices.service_template.refresh_from_db()
result2 = resolver.resolve(service_with_devices, mode="commit")
assert len(result2.to_remove) >= 1
assert ServiceResourceBinding.objects.filter(service=service_with_devices).count() == 1
@pytest.mark.django_db
class TestConflictDetection:
def test_exclusion_when_device_already_bound(self, db, site, device_type, role, tenant):
from dcim.models import Device
dev = Device.objects.create(name="conflict-dev", site=site, device_type=device_type, role=role)
tmpl = ServiceTemplate.objects.create(
name="conflict-tmpl",
status="active",
tree=[{
"name": "dev",
"object_type": "dcim.device",
"quantity": 1,
"selector": {"type": "netbox_filter", "filters": {"role": ["resolver-role"]}},
"scope": "service",
"children": [],
}],
)
svc1 = Service.objects.create(name="c-svc-1", service_template=tmpl, tenant=tenant)
svc2 = Service.objects.create(name="c-svc-2", service_template=tmpl, tenant=tenant)
resolver = Resolver()
r1 = resolver.resolve(svc1, mode="commit")
assert r1.succeeded
assert ServiceResourceBinding.objects.filter(service=svc1).count() == 1
r2 = resolver.resolve(svc2, mode="commit")
assert r2.succeeded
assert ServiceResourceBinding.objects.filter(service=svc2).count() == 0
assert any("only 0 of 1" in w for w in r2.warnings)
def test_no_conflict_different_devices(self, db, devices, tenant):
tmpl = ServiceTemplate.objects.create(
name="noconf-tmpl",
status="active",
tree=[{
"name": "dev",
"object_type": "dcim.device",
"quantity": 1,
"selector": {"type": "netbox_filter", "filters": {"role": ["resolver-role"]}},
"scope": "service",
"children": [],
}],
)
svc1 = Service.objects.create(name="nc-svc-1", service_template=tmpl, tenant=tenant)
svc2 = Service.objects.create(name="nc-svc-2", service_template=tmpl, tenant=tenant)
resolver = Resolver()
r1 = resolver.resolve(svc1, mode="commit")
assert r1.succeeded
r2 = resolver.resolve(svc2, mode="commit")
assert r2.succeeded
b1 = ServiceResourceBinding.objects.filter(service=svc1).first()
b2 = ServiceResourceBinding.objects.filter(service=svc2).first()
assert b1.object_id != b2.object_id
+76
View File
@@ -0,0 +1,76 @@
import pytest
from netbox_services.engine.context import Context
from netbox_services.engine.selectors import NetBoxFilterSelector, ReferenceSelector
@pytest.mark.django_db
class TestContext:
def test_resolve_service(self, service):
ctx = Context(service=service)
assert ctx.resolve_path("service") is service
def test_resolve_parent_none(self, service):
ctx = Context(service=service)
assert ctx.resolve_path("parent") is None
def test_resolve_dotted_path(self, service, service_template):
ctx = Context(service=service)
assert ctx.resolve_path("service.service_template") is service_template
def test_resolve_deep_path(self, service, service_template):
ctx = Context(service=service)
assert ctx.resolve_path("service.service_template.name") == service_template.name
def test_resolve_missing_attr(self, service):
ctx = Context(service=service)
assert ctx.resolve_path("service.nonexistent") is None
def test_child_context(self, service):
ctx = Context(service=service)
parent_obj = object()
child = ctx.child(parent_obj)
assert child.service is service
assert child.parent_object is parent_obj
@pytest.mark.django_db
class TestNetBoxFilterSelector:
def test_filter_devices(self, db):
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
site = Site.objects.create(name="sel-site", slug="sel-site")
mfr = Manufacturer.objects.create(name="sel-mfr", slug="sel-mfr")
dt = DeviceType.objects.create(manufacturer=mfr, model="sel-model", slug="sel-model")
role = DeviceRole.objects.create(name="sel-router", slug="sel-router")
Device.objects.create(name="r1", site=site, device_type=dt, role=role)
Device.objects.create(name="r2", site=site, device_type=dt, role=role)
sel = NetBoxFilterSelector({"type": "netbox_filter", "filters": {"role": ["sel-router"]}})
qs = sel.resolve(Context(service=None), Device)
assert qs.count() == 2
@pytest.mark.django_db
class TestReferenceSelector:
def test_resolve_reference(self, db):
from dcim.models import Site
site = Site.objects.create(name="ref-site", slug="ref-site")
ctx = Context(service=None, parent_object=site)
sel = ReferenceSelector({"type": "reference", "path": "parent"})
qs = sel.resolve(ctx, Site)
assert qs.first() == site
@pytest.mark.django_db
class TestPrefixSelector:
def test_prefix_by_filter(self, db):
from ipam.models import Prefix
from netbox_services.engine.selectors import PrefixSelector
Prefix.objects.create(prefix="10.0.0.0/24")
Prefix.objects.create(prefix="10.1.0.0/24")
sel = PrefixSelector({"type": "prefix", "filters": {}})
qs = sel.resolve(Context(service=None), Prefix)
assert qs.count() == 2
+33
View File
@@ -0,0 +1,33 @@
import pytest
from netbox_services.models import Service, ServiceTemplate
@pytest.mark.django_db
class TestViews:
def test_template_list(self, admin_client):
ServiceTemplate.objects.create(name="view-tmpl")
resp = admin_client.get("/plugins/services/service-templates/")
assert resp.status_code == 200
def test_template_detail(self, admin_client):
st = ServiceTemplate.objects.create(name="view-detail-tmpl", tree=[])
resp = admin_client.get(f"/plugins/services/service-templates/{st.pk}/")
assert resp.status_code == 200
def test_template_add(self, admin_client):
resp = admin_client.get("/plugins/services/service-templates/add/")
assert resp.status_code == 200
def test_service_list(self, admin_client, service_template, tenant):
Service.objects.create(name="view-svc", service_template=service_template, tenant=tenant)
resp = admin_client.get("/plugins/services/services/")
assert resp.status_code == 200
def test_service_detail(self, admin_client, service):
resp = admin_client.get(f"/plugins/services/services/{service.pk}/")
assert resp.status_code == 200
def test_service_resolve_get(self, admin_client, service):
resp = admin_client.get(f"/plugins/services/services/{service.pk}/resolve/")
assert resp.status_code == 200
+11
View File
@@ -0,0 +1,11 @@
from django.urls import include, path
from utilities.urls import get_model_urls
from . import views # noqa: F401 — ensure @register_model_view decorators run
urlpatterns = [
path("service-templates/", include(get_model_urls("netbox_services", "servicetemplate", detail=False))),
path("service-templates/<int:pk>/", include(get_model_urls("netbox_services", "servicetemplate"))),
path("services/", include(get_model_urls("netbox_services", "service", detail=False))),
path("services/<int:pk>/", include(get_model_urls("netbox_services", "service"))),
]
+193
View File
@@ -0,0 +1,193 @@
from django.contrib import messages
from django.contrib.contenttypes.models import ContentType
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.translation import gettext_lazy as _
from netbox.views import generic
from utilities.views import register_model_view
from .engine.resolver import Resolver
from .filtersets import ServiceFilterSet, ServiceTemplateFilterSet
from .forms import (
ResolveForm,
ServiceBulkEditForm,
ServiceFilterSetForm,
ServiceForm,
ServiceTemplateBulkEditForm,
ServiceTemplateFilterSetForm,
ServiceTemplateForm,
ServiceTemplateImportForm,
)
from .models import Service, ServiceTemplate
from .tables import ServiceTable, ServiceTemplateTable
def _build_preview(result):
"""Transform result.planned_objects into a list of dicts with pre-computed action."""
preview = []
for node_name, obj in result.planned_objects:
ct = ContentType.objects.get_for_model(obj.__class__)
key = (ct.id, obj.pk)
action = "add" if key in result.to_add else "keep"
preview.append({"node": node_name, "object": obj, "action": action})
return preview
# ════════════════════════════════════════════════════════════════════
# Service Template
# ════════════════════════════════════════════════════════════════════
@register_model_view(ServiceTemplate, "list", path="", detail=False)
class ServiceTemplateListView(generic.ObjectListView):
queryset = ServiceTemplate.objects.all()
table = ServiceTemplateTable
filterset = ServiceTemplateFilterSet
filterset_form = ServiceTemplateFilterSetForm
@register_model_view(ServiceTemplate, "add", path="add", detail=False)
@register_model_view(ServiceTemplate, "edit")
class ServiceTemplateEditView(generic.ObjectEditView):
queryset = ServiceTemplate.objects.all()
form = ServiceTemplateForm
@register_model_view(ServiceTemplate, "delete")
class ServiceTemplateDeleteView(generic.ObjectDeleteView):
queryset = ServiceTemplate.objects.all()
@register_model_view(ServiceTemplate, "bulk_edit", path="edit", detail=False)
class ServiceTemplateBulkEditView(generic.BulkEditView):
queryset = ServiceTemplate.objects.all()
table = ServiceTemplateTable
form = ServiceTemplateBulkEditForm
@register_model_view(ServiceTemplate, "bulk_delete", path="delete", detail=False)
class ServiceTemplateBulkDeleteView(generic.BulkDeleteView):
queryset = ServiceTemplate.objects.all()
table = ServiceTemplateTable
@register_model_view(ServiceTemplate, "bulk_import", path="import", detail=False)
class ServiceTemplateBulkImportView(generic.BulkImportView):
queryset = ServiceTemplate.objects.all()
model_form = ServiceTemplateImportForm
table = ServiceTemplateTable
@register_model_view(ServiceTemplate)
class ServiceTemplateView(generic.ObjectView):
queryset = ServiceTemplate.objects.all()
def get_extra_context(self, request, instance):
context = super().get_extra_context(request, instance)
context["is_locked"] = instance.is_locked
return context
# ════════════════════════════════════════════════════════════════════
# Service
# ════════════════════════════════════════════════════════════════════
@register_model_view(Service, "list", path="", detail=False)
class ServiceListView(generic.ObjectListView):
queryset = Service.objects.all()
table = ServiceTable
filterset = ServiceFilterSet
filterset_form = ServiceFilterSetForm
@register_model_view(Service, "add", path="add", detail=False)
@register_model_view(Service, "edit")
class ServiceEditView(generic.ObjectEditView):
queryset = Service.objects.all()
form = ServiceForm
@register_model_view(Service, "delete")
class ServiceDeleteView(generic.ObjectDeleteView):
queryset = Service.objects.all()
@register_model_view(Service, "bulk_edit", path="edit", detail=False)
class ServiceBulkEditView(generic.BulkEditView):
queryset = Service.objects.all()
table = ServiceTable
form = ServiceBulkEditForm
@register_model_view(Service, "bulk_delete", path="delete", detail=False)
class ServiceBulkDeleteView(generic.BulkDeleteView):
queryset = Service.objects.all()
table = ServiceTable
@register_model_view(Service)
class ServiceView(generic.ObjectView):
queryset = Service.objects.all()
def get_extra_context(self, request, instance):
context = super().get_extra_context(request, instance)
context["bindings"] = instance.bindings.select_related(
"content_type",
).all()
return context
@register_model_view(Service, "resolve", path="resolve")
class ServiceResolveView(generic.ObjectView):
"""Dry-run preview (GET) and commit (POST) for service resolution."""
queryset = Service.objects.all()
def get(self, request, pk):
service = get_object_or_404(self.queryset, pk=pk)
resolver = Resolver()
try:
result = resolver.resolve(service, mode="dry_run", user=request.user)
except Exception as exc:
messages.error(request, f"Resolution failed: {exc}")
return redirect("plugins:netbox_services:service", pk=service.pk)
return render(request, "netbox_services/service_resolve.html", {
"object": service,
"service": service,
"result": result,
"planned_preview": _build_preview(result),
"form": ResolveForm(initial={"mode": "commit"}),
"active_tab": "resolve",
})
def post(self, request, pk):
service = get_object_or_404(self.queryset, pk=pk)
form = ResolveForm(request.POST)
mode = form.cleaned_data.get("mode", "dry_run") if form.is_valid() else "dry_run"
resolver = Resolver()
try:
result = resolver.resolve(service, mode=mode, user=request.user)
except Exception as exc:
messages.error(request, f"Resolution failed: {exc}")
return redirect("plugins:netbox_services:service", pk=service.pk)
if mode == "commit":
messages.success(
request,
_("Resolution committed: {added} added, {removed} removed, {kept} unchanged.").format(
added=len(result.to_add),
removed=len(result.to_remove),
kept=result.total_planned - len(result.to_add),
),
)
return redirect("plugins:netbox_services:service", pk=service.pk)
return render(request, "netbox_services/service_resolve.html", {
"object": service,
"service": service,
"result": result,
"planned_preview": _build_preview(result),
"form": ResolveForm(initial={"mode": "commit"}),
"active_tab": "resolve",
})