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}"