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

Merge branch 'develop' into feature

This commit is contained in:
Jeremy Stretch
2024-02-21 16:24:23 -05:00
98 changed files with 9468 additions and 4748 deletions

View File

@ -1,4 +1,5 @@
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _
from drf_spectacular.utils import extend_schema_field
from drf_spectacular.types import OpenApiTypes
from rest_framework.fields import Field
@ -88,7 +89,7 @@ class CustomFieldsDataField(Field):
if serializer.is_valid():
data[cf.name] = [obj['id'] for obj in serializer.data] if many else serializer.data['id']
else:
raise ValidationError(f"Unknown related object(s): {data[cf.name]}")
raise ValidationError(_("Unknown related object(s): {name}").format(name=data[cf.name]))
# If updating an existing instance, start with existing custom_field_data
if self.parent.instance:

View File

@ -1,5 +1,6 @@
from django.contrib.auth import get_user_model
from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import gettext as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
@ -147,7 +148,7 @@ class CustomFieldSerializer(ValidatedModelSerializer):
def validate_type(self, value):
if self.instance and self.instance.type != value:
raise serializers.ValidationError('Changing the type of custom fields is not supported.')
raise serializers.ValidationError(_('Changing the type of custom fields is not supported.'))
return value
@ -544,12 +545,12 @@ class ReportInputSerializer(serializers.Serializer):
def validate_schedule_at(self, value):
if value and not self.context['report'].scheduling_enabled:
raise serializers.ValidationError("Scheduling is not enabled for this report.")
raise serializers.ValidationError(_("Scheduling is not enabled for this report."))
return value
def validate_interval(self, value):
if value and not self.context['report'].scheduling_enabled:
raise serializers.ValidationError("Scheduling is not enabled for this report.")
raise serializers.ValidationError(_("Scheduling is not enabled for this report."))
return value
@ -594,12 +595,12 @@ class ScriptInputSerializer(serializers.Serializer):
def validate_schedule_at(self, value):
if value and not self.context['script'].scheduling_enabled:
raise serializers.ValidationError("Scheduling is not enabled for this script.")
raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
return value
def validate_interval(self, value):
if value and not self.context['script'].scheduling_enabled:
raise serializers.ValidationError("Scheduling is not enabled for this script.")
raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
return value

View File

@ -1,5 +1,6 @@
import functools
import re
from django.utils.translation import gettext as _
__all__ = (
'Condition',
@ -50,11 +51,13 @@ class Condition:
def __init__(self, attr, value, op=EQ, negate=False):
if op not in self.OPERATORS:
raise ValueError(f"Unknown operator: {op}. Must be one of: {', '.join(self.OPERATORS)}")
raise ValueError(_("Unknown operator: {op}. Must be one of: {operators}").format(
op=op, operators=', '.join(self.OPERATORS)
))
if type(value) not in self.TYPES:
raise ValueError(f"Unsupported value type: {type(value)}")
raise ValueError(_("Unsupported value type: {value}").format(value=type(value)))
if op not in self.TYPES[type(value)]:
raise ValueError(f"Invalid type for {op} operation: {type(value)}")
raise ValueError(_("Invalid type for {op} operation: {value}").format(op=op, value=type(value)))
self.attr = attr
self.value = value
@ -131,14 +134,17 @@ class ConditionSet:
"""
def __init__(self, ruleset):
if type(ruleset) is not dict:
raise ValueError(f"Ruleset must be a dictionary, not {type(ruleset)}.")
raise ValueError(_("Ruleset must be a dictionary, not {ruleset}.").format(ruleset=type(ruleset)))
if len(ruleset) != 1:
raise ValueError(f"Ruleset must have exactly one logical operator (found {len(ruleset)})")
raise ValueError(_("Ruleset must have exactly one logical operator (found {ruleset})").format(
ruleset=len(ruleset)))
# Determine the logic type
logic = list(ruleset.keys())[0]
if type(logic) is not str or logic.lower() not in (AND, OR):
raise ValueError(f"Invalid logic type: {logic} (must be '{AND}' or '{OR}')")
raise ValueError(_("Invalid logic type: {logic} (must be '{op_and}' or '{op_or}')").format(
logic=logic, op_and=AND, op_or=OR
))
self.logic = logic.lower()
# Compile the set of Conditions

View File

@ -2,6 +2,7 @@ import uuid
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import gettext as _
from netbox.registry import registry
from extras.constants import DEFAULT_DASHBOARD
@ -32,7 +33,7 @@ def get_widget_class(name):
try:
return registry['widgets'][name]
except KeyError:
raise ValueError(f"Unregistered widget class: {name}")
raise ValueError(_("Unregistered widget class: {name}").format(name=name))
def get_dashboard(user):

View File

@ -111,7 +111,9 @@ class DashboardWidget:
Params:
request: The current request
"""
raise NotImplementedError(f"{self.__class__} must define a render() method.")
raise NotImplementedError(_("{class_name} must define a render() method.").format(
class_name=self.__class__
))
@property
def name(self):
@ -177,7 +179,7 @@ class ObjectCountsWidget(DashboardWidget):
try:
dict(data)
except TypeError:
raise forms.ValidationError("Invalid format. Object filters must be passed as a dictionary.")
raise forms.ValidationError(_("Invalid format. Object filters must be passed as a dictionary."))
return data
def render(self, request):
@ -231,7 +233,7 @@ class ObjectListWidget(DashboardWidget):
try:
urlencode(data)
except (TypeError, ValueError):
raise forms.ValidationError("Invalid format. URL parameters must be passed as a dictionary.")
raise forms.ValidationError(_("Invalid format. URL parameters must be passed as a dictionary."))
return data
def render(self, request):

View File

@ -6,6 +6,7 @@ from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.utils import timezone
from django.utils.module_loading import import_string
from django.utils.translation import gettext as _
from django_rq import get_queue
from core.models import Job
@ -129,7 +130,9 @@ def process_event_rules(event_rules, model_name, event, data, username=None, sna
)
else:
raise ValueError(f"Unknown action type for an event rule: {event_rule.action_type}")
raise ValueError(_("Unknown action type for an event rule: {action_type}").format(
action_type=event_rule.action_type
))
def process_event_queue(events):
@ -175,4 +178,4 @@ def flush_events(queue):
func = import_string(name)
func(queue)
except Exception as e:
logger.error(f"Cannot import events pipeline {name} error: {e}")
logger.error(_("Cannot import events pipeline {name} error: {error}").format(name=name, error=e))

View File

@ -202,7 +202,7 @@ class EventRuleImportForm(NetBoxModelImportForm):
try:
webhook = Webhook.objects.get(name=action_object)
except Webhook.DoesNotExist:
raise forms.ValidationError(f"Webhook {action_object} not found")
raise forms.ValidationError(_("Webhook {name} not found").format(name=action_object))
self.instance.action_object = webhook
# Script
elif action_type == EventRuleActionChoices.SCRIPT:
@ -211,7 +211,7 @@ class EventRuleImportForm(NetBoxModelImportForm):
try:
module, script = get_module_and_script(module_name, script_name)
except ObjectDoesNotExist:
raise forms.ValidationError(f"Script {action_object} not found")
raise forms.ValidationError(_("Script {name} not found").format(name=action_object))
self.instance.action_object = module
self.instance.action_object_type = ContentType.objects.get_for_model(module, for_concrete_model=False)
self.instance.action_parameters = {

View File

@ -1,5 +1,6 @@
from django.contrib.contenttypes.models import ContentType
from django.core.management.base import BaseCommand, CommandError
from django.utils.translation import gettext as _
from netbox.registry import registry
from netbox.search.backends import search_backend
@ -62,7 +63,7 @@ class Command(BaseCommand):
# Determine which models to reindex
indexers = self._get_indexers(*model_labels)
if not indexers:
raise CommandError("No indexers found!")
raise CommandError(_("No indexers found!"))
self.stdout.write(f'Reindexing {len(indexers)} models.')
# Clear all cached values for the specified models (if not being lazy)

View File

@ -0,0 +1,17 @@
# Generated by Django 4.2.9 on 2024-02-20 17:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0106_bookmark_user_cascade_deletion'),
]
operations = [
migrations.AddIndex(
model_name='cachedvalue',
index=models.Index(fields=['object_type', 'object_id'], name='extras_cachedvalue_object'),
),
]

View File

@ -14,7 +14,7 @@ def convert_reportmodule_jobs(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
('extras', '0106_bookmark_user_cascade_deletion'),
('extras', '0107_cachedvalue_extras_cachedvalue_object'),
]
operations = [

View File

@ -57,6 +57,9 @@ class CachedValue(models.Model):
ordering = ('weight', 'object_type', 'value', 'object_id')
verbose_name = _('cached value')
verbose_name_plural = _('cached values')
indexes = (
models.Index(fields=('object_type', 'object_id'), name='extras_cachedvalue_object'),
)
def __str__(self):
return f'{self.object_type} {self.object_id}: {self.field}={self.value}'

View File

@ -411,11 +411,11 @@ class BaseScript:
fieldsets.extend(self.fieldsets)
else:
fields = list(name for name, _ in self._get_vars().items())
fieldsets.append(('Script Data', fields))
fieldsets.append((_('Script Data'), fields))
# Append the default fieldset if defined in the Meta class
exec_parameters = ('_schedule_at', '_interval', '_commit') if self.scheduling_enabled else ('_commit',)
fieldsets.append(('Script Execution Parameters', exec_parameters))
fieldsets.append((_('Script Execution Parameters'), exec_parameters))
return fieldsets

View File

@ -1,8 +1,8 @@
import importlib
import logging
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db.models.fields.reverse_related import ManyToManyRel
from django.db.models.signals import m2m_changed, post_save, pre_delete
from django.dispatch import receiver, Signal
from django.utils.translation import gettext_lazy as _
@ -12,9 +12,10 @@ from core.signals import job_end, job_start
from extras.constants import EVENT_JOB_END, EVENT_JOB_START
from extras.events import process_event_rules
from extras.models import EventRule
from extras.validators import CustomValidator
from extras.validators import run_validators
from netbox.config import get_config
from netbox.context import current_request, events_queue
from netbox.models.features import ChangeLoggingMixin
from netbox.signals import post_clean
from utilities.exceptions import AbortRequest
from .choices import ObjectChangeActionChoices
@ -68,7 +69,7 @@ def handle_changed_object(sender, instance, **kwargs):
else:
return
# Create/update an ObejctChange record for this change
# Create/update an ObjectChange record for this change
objectchange = instance.to_objectchange(action)
# If this is a many-to-many field change, check for a previous ObjectChange instance recorded
# for this object by this request and update it
@ -108,6 +109,18 @@ def handle_deleted_object(sender, instance, **kwargs):
"""
Fires when an object is deleted.
"""
# Run any deletion protection rules for the object. Note that this must occur prior
# to queueing any events for the object being deleted, in case a validation error is
# raised, causing the deletion to fail.
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
validators = get_config().PROTECTION_RULES.get(model_name, [])
try:
run_validators(instance, validators)
except ValidationError as e:
raise AbortRequest(
_("Deletion is prevented by a protection rule: {message}").format(message=e)
)
# Get the current request, or bail if not set
request = current_request.get()
if request is None:
@ -122,6 +135,25 @@ def handle_deleted_object(sender, instance, **kwargs):
objectchange.request_id = request.id
objectchange.save()
# Django does not automatically send an m2m_changed signal for the reverse direction of a
# many-to-many relationship (see https://code.djangoproject.com/ticket/17688), so we need to
# trigger one manually. We do this by checking for any reverse M2M relationships on the
# instance being deleted, and explicitly call .remove() on the remote M2M field to delete
# the association. This triggers an m2m_changed signal with the `post_remove` action type
# for the forward direction of the relationship, ensuring that the change is recorded.
for relation in instance._meta.related_objects:
if type(relation) is not ManyToManyRel:
continue
related_model = relation.related_model
related_field_name = relation.remote_field.name
if not issubclass(related_model, ChangeLoggingMixin):
# We only care about triggering the m2m_changed signal for models which support
# change logging
continue
for obj in related_model.objects.filter(**{related_field_name: instance.pk}):
obj.snapshot() # Ensure the change record includes the "before" state
getattr(obj, related_field_name).remove(instance)
# Enqueue webhooks
queue = events_queue.get()
enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
@ -186,45 +218,17 @@ m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_type
# Custom validation
#
def run_validators(instance, validators):
for validator in validators:
# Loading a validator class by dotted path
if type(validator) is str:
module, cls = validator.rsplit('.', 1)
validator = getattr(importlib.import_module(module), cls)()
# Constructing a new instance on the fly from a ruleset
elif type(validator) is dict:
validator = CustomValidator(validator)
validator(instance)
@receiver(post_clean)
def run_save_validators(sender, instance, **kwargs):
"""
Run any custom validation rules for the model prior to calling save().
"""
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
validators = get_config().CUSTOM_VALIDATORS.get(model_name, [])
run_validators(instance, validators)
@receiver(pre_delete)
def run_delete_validators(sender, instance, **kwargs):
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
validators = get_config().PROTECTION_RULES.get(model_name, [])
try:
run_validators(instance, validators)
except ValidationError as e:
raise AbortRequest(
_("Deletion is prevented by a protection rule: {message}").format(
message=e
)
)
#
# Tags
#

View File

@ -1,3 +1,5 @@
import importlib
from django.core import validators
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
@ -149,3 +151,21 @@ class CustomValidator:
if field is not None:
raise ValidationError({field: message})
raise ValidationError(message)
def run_validators(instance, validators):
"""
Run the provided iterable of validators for the instance.
"""
for validator in validators:
# Loading a validator class by dotted path
if type(validator) is str:
module, cls = validator.rsplit('.', 1)
validator = getattr(importlib.import_module(module), cls)()
# Constructing a new instance on the fly from a ruleset
elif type(validator) is dict:
validator = CustomValidator(validator)
validator(instance)