1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

304 lines
9.3 KiB
Python
Raw Normal View History

from django.apps import apps
from django.conf import settings
from django.core.validators import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from jinja2.loaders import BaseLoader
from jinja2.sandbox import SandboxedEnvironment
from extras.querysets import ConfigContextQuerySet
from netbox.config import get_config
from netbox.registry import registry
from netbox.models import ChangeLoggedModel
2023-03-28 14:19:08 -04:00
from netbox.models.features import CloningMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin
from utilities.jinja2 import ConfigTemplateLoader
from utilities.utils import deepmerge
__all__ = (
'ConfigContext',
'ConfigContextModel',
'ConfigTemplate',
)
#
# Config contexts
#
2023-03-28 14:19:08 -04:00
class ConfigContext(SyncedDataMixin, CloningMixin, ChangeLoggedModel):
"""
A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B
will be available to a Device in site A assigned to tenant B. Data is stored in JSON format.
"""
name = models.CharField(
verbose_name=_('name'),
max_length=100,
unique=True
)
weight = models.PositiveSmallIntegerField(
verbose_name=_('weight'),
default=1000
)
description = models.CharField(
verbose_name=_('description'),
max_length=200,
blank=True
)
is_active = models.BooleanField(
verbose_name=_('is active'),
default=True,
)
regions = models.ManyToManyField(
to='dcim.Region',
related_name='+',
blank=True
)
site_groups = models.ManyToManyField(
to='dcim.SiteGroup',
related_name='+',
blank=True
)
sites = models.ManyToManyField(
to='dcim.Site',
related_name='+',
blank=True
)
locations = models.ManyToManyField(
to='dcim.Location',
related_name='+',
blank=True
)
device_types = models.ManyToManyField(
to='dcim.DeviceType',
related_name='+',
blank=True
)
roles = models.ManyToManyField(
to='dcim.DeviceRole',
related_name='+',
blank=True
)
platforms = models.ManyToManyField(
to='dcim.Platform',
related_name='+',
blank=True
)
cluster_types = models.ManyToManyField(
to='virtualization.ClusterType',
related_name='+',
blank=True
)
cluster_groups = models.ManyToManyField(
to='virtualization.ClusterGroup',
related_name='+',
blank=True
)
clusters = models.ManyToManyField(
to='virtualization.Cluster',
related_name='+',
blank=True
)
tenant_groups = models.ManyToManyField(
to='tenancy.TenantGroup',
related_name='+',
blank=True
)
tenants = models.ManyToManyField(
to='tenancy.Tenant',
related_name='+',
blank=True
)
tags = models.ManyToManyField(
to='extras.Tag',
related_name='+',
blank=True
)
data = models.JSONField()
objects = ConfigContextQuerySet.as_manager()
2023-03-27 09:13:05 -07:00
clone_fields = (
'weight', 'is_active', 'regions', 'site_groups', 'sites', 'locations', 'device_types',
'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
'tenants', 'tags', 'data',
)
class Meta:
ordering = ['weight', 'name']
verbose_name = _('config context')
verbose_name_plural = _('config contexts')
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('extras:configcontext', kwargs={'pk': self.pk})
@property
def docs_url(self):
return f'{settings.STATIC_URL}docs/models/extras/configcontext/'
def clean(self):
super().clean()
# Verify that JSON data is provided as an object
if type(self.data) is not dict:
raise ValidationError(
2023-09-11 15:59:50 -04:00
{'data': _('JSON data must be in object form. Example:') + ' {"foo": 123}'}
)
def sync_data(self):
"""
Synchronize context data from the designated DataFile (if any).
"""
self.data = self.data_file.get_data()
2023-05-31 13:47:22 -07:00
sync_data.alters_data = True
class ConfigContextModel(models.Model):
"""
A model which includes local configuration context data. This local data will override any inherited data from
ConfigContexts.
"""
local_context_data = models.JSONField(
blank=True,
null=True,
help_text=_(
"Local config context data takes precedence over source contexts in the final rendered config context"
)
)
class Meta:
abstract = True
def get_config_context(self):
"""
Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs.
Return the rendered configuration context for a device or VM.
"""
data = {}
if not hasattr(self, 'config_context_data'):
# The annotation is not available, so we fall back to manually querying for the config context objects
config_context_data = ConfigContext.objects.get_for_object(self, aggregate_data=True)
else:
# The attribute may exist, but the annotated value could be None if there is no config context data
config_context_data = self.config_context_data or []
for context in config_context_data:
data = deepmerge(data, context)
# If the object has local config context data defined, merge it last
if self.local_context_data:
data = deepmerge(data, self.local_context_data)
return data
def clean(self):
super().clean()
# Verify that JSON data is provided as an object
if self.local_context_data and type(self.local_context_data) is not dict:
raise ValidationError(
2023-09-11 15:59:50 -04:00
{'local_context_data': _('JSON data must be in object form. Example:') + ' {"foo": 123}'}
)
#
# Config templates
#
class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100
)
description = models.CharField(
verbose_name=_('description'),
max_length=200,
blank=True
)
template_code = models.TextField(
verbose_name=_('template code'),
help_text=_('Jinja2 template code.')
)
environment_params = models.JSONField(
verbose_name=_('environment parameters'),
blank=True,
null=True,
default=dict,
help_text=_(
'Any <a href="https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment">additional parameters</a>'
' to pass when constructing the Jinja2 environment.'
)
)
class Meta:
ordering = ('name',)
verbose_name = _('config template')
verbose_name_plural = _('config templates')
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('extras:configtemplate', args=[self.pk])
def sync_data(self):
"""
Synchronize template content from the designated DataFile (if any).
"""
self.template_code = self.data_file.data_as_string
2023-05-31 13:47:22 -07:00
sync_data.alters_data = True
def render(self, context=None):
"""
Render the contents of the template.
"""
_context = dict()
# Populate the default template context with NetBox model classes, namespaced by app
# TODO: Devise a canonical mechanism for identifying the models to include (see #13427)
for app, model_names in registry['model_features']['custom_fields'].items():
_context.setdefault(app, {})
for model_name in model_names:
model = apps.get_registered_model(app, model_name)
_context[app][model.__name__] = model
# Add the provided context data, if any
if context is not None:
_context.update(context)
# Initialize the Jinja2 environment and instantiate the Template
environment = self._get_environment()
if self.data_file:
template = environment.get_template(self.data_file.path)
else:
template = environment.from_string(self.template_code)
output = template.render(**_context)
# Replace CRLF-style line terminators
return output.replace('\r\n', '\n')
def _get_environment(self):
"""
Instantiate and return a Jinja2 environment suitable for rendering the ConfigTemplate.
"""
# Initialize the template loader & cache the base template code (if applicable)
if self.data_file:
loader = ConfigTemplateLoader(data_source=self.data_source)
loader.cache_templates({
self.data_file.path: self.template_code
})
else:
loader = BaseLoader()
# Initialize the environment
env_params = self.environment_params or {}
environment = SandboxedEnvironment(loader=loader, **env_params)
environment.filters.update(get_config().JINJA2_FILTERS)
return environment