Initial commit
This commit is contained in:
commit
4ff9eac274
45 files changed
+2911
No files matched your search
@@ -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}"
|
||||
Reference in new issue
Block a user