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

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