Merge pull request #6659 from netbox-community/6466-replace-django-admin

Closes #6646: Move extras out of Django admin UI
This commit is contained in:
Jeremy Stretch 2021-06-24 09:04:08 -04:00 committed by GitHub
commit 442b3fcc48
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 1403 additions and 236 deletions

View File

@ -1,6 +1,6 @@
# Webhooks # Webhooks
A webhook is a mechanism for conveying to some external system a change that took place in NetBox. For example, you may want to notify a monitoring system whenever the status of a device is updated in NetBox. This can be done by creating a webhook for the device model in NetBox and identifying the webhook receiver. When NetBox detects a change to a device, an HTTP request containing the details of the change and who made it be sent to the specified receiver. Webhooks are configured in the admin UI under Extras > Webhooks. A webhook is a mechanism for conveying to some external system a change that took place in NetBox. For example, you may want to notify a monitoring system whenever the status of a device is updated in NetBox. This can be done by creating a webhook for the device model in NetBox and identifying the webhook receiver. When NetBox detects a change to a device, an HTTP request containing the details of the change and who made it be sent to the specified receiver. Webhooks are managed under Logging > Webhooks.
## Configuration ## Configuration

View File

@ -98,7 +98,7 @@ CORS_ORIGIN_WHITELIST = [
## CUSTOM_VALIDATORS ## CUSTOM_VALIDATORS
This is a mapping of models to [custom validators](../additional-features/custom-validation.md) that have been defined locally to enforce custom validation logic. This is a mapping of models to [custom validators](../customization/custom-validation.md) that have been defined locally to enforce custom validation logic.
--- ---
@ -501,7 +501,7 @@ This parameter defines the URL of the repository that will be checked periodical
Default: `$INSTALL_ROOT/netbox/reports/` Default: `$INSTALL_ROOT/netbox/reports/`
The file path to the location where custom reports will be kept. By default, this is the `netbox/reports/` directory within the base NetBox installation path. The file path to the location where [custom reports](../customization/reports.md) will be kept. By default, this is the `netbox/reports/` directory within the base NetBox installation path.
--- ---
@ -517,7 +517,7 @@ The maximum execution time of a background task (such as running a custom script
Default: `$INSTALL_ROOT/netbox/scripts/` Default: `$INSTALL_ROOT/netbox/scripts/`
The file path to the location where custom scripts will be kept. By default, this is the `netbox/scripts/` directory within the base NetBox installation path. The file path to the location where [custom scripts](../customization/custom-scripts.md) will be kept. By default, this is the `netbox/scripts/` directory within the base NetBox installation path.
--- ---

View File

@ -8,7 +8,7 @@ Within the database, custom fields are stored as JSON data directly alongside ea
## Creating Custom Fields ## Creating Custom Fields
Custom fields must be created through the admin UI under Extras > Custom Fields. NetBox supports six types of custom field: Custom fields may be created by navigating to Customization > Custom Fields. NetBox supports six types of custom field:
* Text: Free-form text (up to 255 characters) * Text: Free-form text (up to 255 characters)
* Integer: A whole number (positive or negative) * Integer: A whole number (positive or negative)

View File

@ -1,8 +1,8 @@
# Custom Links # Custom Links
Custom links allow users to display arbitrary hyperlinks to external content within NetBox object views. These are helpful for cross-referencing related records in systems outside of NetBox. For example, you might create a custom link on the device view which links to the current device in a network monitoring system. Custom links allow users to display arbitrary hyperlinks to external content within NetBox object views. These are helpful for cross-referencing related records in systems outside NetBox. For example, you might create a custom link on the device view which links to the current device in a network monitoring system.
Custom links are created under the admin UI. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link is assigned text and a URL, both of which support Jinja2 templating. The text and URL are rendered with the context variable `obj` representing the current object. Custom links are created by navigating to Customization > Custom Links. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link is assigned text and a URL, both of which support Jinja2 templating. The text and URL are rendered with the context variable `obj` representing the current object.
For example, you might define a link like this: For example, you might define a link like this:
@ -15,7 +15,7 @@ When viewing a device named Router4, this link would render as:
<a href="https://nms.example.com/nodes/?name=Router4">View NMS</a> <a href="https://nms.example.com/nodes/?name=Router4">View NMS</a>
``` ```
Custom links appear as buttons at the top right corner of the page. Numeric weighting can be used to influence the ordering of links. Custom links appear as buttons in the top right corner of the page. Numeric weighting can be used to influence the ordering of links.
## Context Data ## Context Data

View File

@ -1,6 +1,6 @@
# Export Templates # Export Templates
NetBox allows users to define custom templates that can be used when exporting objects. To create an export template, navigate to Extras > Export Templates under the admin interface. NetBox allows users to define custom templates that can be used when exporting objects. To create an export template, navigate to Customization > Export Templates.
Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list. Each export template must have a name, and may optionally designate a specific export [MIME type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types) and/or file extension. Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list. Each export template must have a name, and may optionally designate a specific export [MIME type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types) and/or file extension.

View File

@ -10,8 +10,8 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
* [Change logging](../additional-features/change-logging.md) - Changes to these objects are automatically recorded in the change log * [Change logging](../additional-features/change-logging.md) - Changes to these objects are automatically recorded in the change log
* [Webhooks](../additional-features/webhooks.md) - NetBox is capable of generating outgoing webhooks for these objects * [Webhooks](../additional-features/webhooks.md) - NetBox is capable of generating outgoing webhooks for these objects
* [Custom fields](../additional-features/custom-fields.md) - These models support the addition of user-defined fields * [Custom fields](../customization/custom-fields.md) - These models support the addition of user-defined fields
* [Export templates](../additional-features/export-templates.md) - Users can create custom export templates for these models * [Export templates](../customization/export-templates.md) - Users can create custom export templates for these models
* [Tagging](../models/extras/tag.md) - The models can be tagged with user-defined tags * [Tagging](../models/extras/tag.md) - The models can be tagged with user-defined tags
* [Journaling](../additional-features/journaling.md) - These models support persistent historical commentary * [Journaling](../additional-features/journaling.md) - These models support persistent historical commentary
* Nesting - These models can be nested recursively to create a hierarchy * Nesting - These models can be nested recursively to create a hierarchy

View File

@ -218,7 +218,7 @@
#### Custom Scripts ([#3415](https://github.com/netbox-community/netbox/issues/3415)) #### Custom Scripts ([#3415](https://github.com/netbox-community/netbox/issues/3415))
Custom scripts allow for the execution of arbitrary code via the NetBox UI. They can be used to automatically create, manipulate, or clean up objects or perform other tasks within NetBox. Scripts are defined as Python files which contain one or more subclasses of `extras.scripts.Script`. Variable fields can be defined within scripts, which render as form fields within the web UI to prompt the user for input data. Scripts are executed and information is logged via the web UI. Please see [the docs](https://netbox.readthedocs.io/en/stable/additional-features/custom-scripts/) for more detail. Custom scripts allow for the execution of arbitrary code via the NetBox UI. They can be used to automatically create, manipulate, or clean up objects or perform other tasks within NetBox. Scripts are defined as Python files which contain one or more subclasses of `extras.scripts.Script`. Variable fields can be defined within scripts, which render as form fields within the web UI to prompt the user for input data. Scripts are executed and information is logged via the web UI. Please see [the docs](https://netbox.readthedocs.io/en/stable/customization/custom-scripts/) for more detail.
Note: There are currently no API endpoints for this feature. These are planned for the upcoming v2.7 release. Note: There are currently no API endpoints for this feature. These are planned for the upcoming v2.7 release.

View File

@ -46,7 +46,7 @@ CUSTOM_VALIDATORS = {
} }
``` ```
CustomValidator can also be subclassed to enforce more complex logic by overriding its `validate()` method. See the [custom validation](../additional-features/custom-validation.md) documentation for more details. CustomValidator can also be subclassed to enforce more complex logic by overriding its `validate()` method. See the [custom validation](../customization/custom-validation.md) documentation for more details.
### Enhancements ### Enhancements

View File

@ -59,19 +59,20 @@ nav:
- Circuits: 'core-functionality/circuits.md' - Circuits: 'core-functionality/circuits.md'
- Power Tracking: 'core-functionality/power.md' - Power Tracking: 'core-functionality/power.md'
- Tenancy: 'core-functionality/tenancy.md' - Tenancy: 'core-functionality/tenancy.md'
- Customization:
- Custom Fields: 'customization/custom-fields.md'
- Custom Validation: 'customization/custom-validation.md'
- Custom Links: 'customization/custom-links.md'
- Export Templates: 'customization/export-templates.md'
- Custom Scripts: 'customization/custom-scripts.md'
- Reports: 'customization/reports.md'
- Additional Features: - Additional Features:
- Caching: 'additional-features/caching.md' - Caching: 'additional-features/caching.md'
- Change Logging: 'additional-features/change-logging.md' - Change Logging: 'additional-features/change-logging.md'
- Context Data: 'models/extras/configcontext.md' - Context Data: 'models/extras/configcontext.md'
- Custom Fields: 'additional-features/custom-fields.md'
- Custom Validation: 'additional-features/custom-validation.md'
- Custom Links: 'additional-features/custom-links.md'
- Custom Scripts: 'additional-features/custom-scripts.md'
- Export Templates: 'additional-features/export-templates.md'
- Journaling: 'additional-features/journaling.md' - Journaling: 'additional-features/journaling.md'
- NAPALM: 'additional-features/napalm.md' - NAPALM: 'additional-features/napalm.md'
- Prometheus Metrics: 'additional-features/prometheus-metrics.md' - Prometheus Metrics: 'additional-features/prometheus-metrics.md'
- Reports: 'additional-features/reports.md'
- Tags: 'models/extras/tag.md' - Tags: 'models/extras/tag.md'
- Webhooks: 'additional-features/webhooks.md' - Webhooks: 'additional-features/webhooks.md'
- Plugins: - Plugins:

View File

@ -1,200 +1,6 @@
from django import forms
from django.contrib import admin from django.contrib import admin
from django.contrib.contenttypes.models import ContentType
from django.utils.safestring import mark_safe
from utilities.forms import ContentTypeChoiceField, ContentTypeMultipleChoiceField, LaxURLField from .models import JobResult
from utilities.utils import content_type_name
from .models import CustomField, CustomLink, ExportTemplate, JobResult, Webhook
from .utils import FeatureQuery
#
# Webhooks
#
class WebhookForm(forms.ModelForm):
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('webhooks')
)
payload_url = LaxURLField(
label='URL'
)
class Meta:
model = Webhook
exclude = ()
@admin.register(Webhook)
class WebhookAdmin(admin.ModelAdmin):
list_display = [
'name', 'models', 'payload_url', 'http_content_type', 'enabled', 'type_create', 'type_update', 'type_delete',
'ssl_verification',
]
list_filter = [
'enabled', 'type_create', 'type_update', 'type_delete', 'content_types',
]
form = WebhookForm
fieldsets = (
(None, {
'fields': ('name', 'content_types', 'enabled')
}),
('Events', {
'fields': ('type_create', 'type_update', 'type_delete')
}),
('HTTP Request', {
'fields': (
'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
),
'classes': ('monospace',)
}),
('SSL', {
'fields': ('ssl_verification', 'ca_file_path')
})
)
def models(self, obj):
return ', '.join([ct.name for ct in obj.content_types.all()])
#
# Custom fields
#
class CustomFieldForm(forms.ModelForm):
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields')
)
class Meta:
model = CustomField
exclude = []
widgets = {
'default': forms.TextInput(),
'validation_regex': forms.Textarea(
attrs={
'cols': 80,
'rows': 3,
}
)
}
@admin.register(CustomField)
class CustomFieldAdmin(admin.ModelAdmin):
actions = None
form = CustomFieldForm
list_display = [
'name', 'models', 'type', 'required', 'filter_logic', 'default', 'weight', 'description',
]
list_filter = [
'type', 'required', 'content_types',
]
fieldsets = (
('Custom Field', {
'fields': ('type', 'name', 'weight', 'label', 'description', 'required', 'default', 'filter_logic')
}),
('Assignment', {
'description': 'A custom field must be assigned to one or more object types.',
'fields': ('content_types',)
}),
('Validation Rules', {
'fields': ('validation_minimum', 'validation_maximum', 'validation_regex'),
'classes': ('monospace',)
}),
('Choices', {
'description': 'A selection field must have two or more choices assigned to it.',
'fields': ('choices',)
})
)
def models(self, obj):
ct_names = [content_type_name(ct) for ct in obj.content_types.all()]
return mark_safe('<br/>'.join(ct_names))
#
# Custom links
#
class CustomLinkForm(forms.ModelForm):
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_links')
)
class Meta:
model = CustomLink
exclude = []
widgets = {
'link_text': forms.Textarea,
'link_url': forms.Textarea,
}
help_texts = {
'weight': 'A numeric weight to influence the ordering of this link among its peers. Lower weights appear '
'first in a list.',
'link_text': 'Jinja2 template code for the link text. Reference the object as <code>{{ obj }}</code>. '
'Links which render as empty text will not be displayed.',
'link_url': 'Jinja2 template code for the link URL. Reference the object as <code>{{ obj }}</code>.',
}
@admin.register(CustomLink)
class CustomLinkAdmin(admin.ModelAdmin):
fieldsets = (
('Custom Link', {
'fields': ('content_type', 'name', 'group_name', 'weight', 'button_class', 'new_window')
}),
('Templates', {
'fields': ('link_text', 'link_url'),
'classes': ('monospace',)
})
)
list_display = [
'name', 'content_type', 'group_name', 'weight',
]
list_filter = [
'content_type',
]
form = CustomLinkForm
#
# Export templates
#
class ExportTemplateForm(forms.ModelForm):
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_links')
)
class Meta:
model = ExportTemplate
exclude = []
@admin.register(ExportTemplate)
class ExportTemplateAdmin(admin.ModelAdmin):
fieldsets = (
('Export Template', {
'fields': ('content_type', 'name', 'description', 'mime_type', 'file_extension', 'as_attachment')
}),
('Content', {
'fields': ('template_code',),
'classes': ('monospace',)
})
)
list_display = [
'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment',
]
list_filter = [
'content_type',
]
form = ExportTemplateForm
# #

View File

@ -8,12 +8,13 @@ from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGrou
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.forms import ( from utilities.forms import (
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorField, add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorField,
CommentField, ContentTypeMultipleChoiceField, CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField, CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, CSVContentTypeField, CSVModelForm,
JSONField, SlugField, StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES, CSVMultipleContentTypeField, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect2,
StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES,
) )
from virtualization.models import Cluster, ClusterGroup from virtualization.models import Cluster, ClusterGroup
from .choices import * from .choices import *
from .models import ConfigContext, CustomField, ImageAttachment, JournalEntry, ObjectChange, Tag from .models import *
from .utils import FeatureQuery from .utils import FeatureQuery
@ -21,6 +22,379 @@ from .utils import FeatureQuery
# Custom fields # Custom fields
# #
class CustomFieldForm(BootstrapMixin, forms.ModelForm):
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields')
)
class Meta:
model = CustomField
fields = '__all__'
fieldsets = (
('Custom Field', ('name', 'label', 'type', 'weight', 'required', 'description')),
('Assigned Models', ('content_types',)),
('Behavior', ('filter_logic',)),
('Values', ('default', 'choices')),
('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
)
class CustomFieldCSVForm(CSVModelForm):
content_types = CSVMultipleContentTypeField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields'),
help_text="One or more assigned object types"
)
class Meta:
model = CustomField
fields = (
'name', 'label', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', 'default',
'weight',
)
class CustomFieldBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=CustomField.objects.all(),
widget=forms.MultipleHiddenInput
)
description = forms.CharField(
required=False
)
required = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
weight = forms.IntegerField(
required=False
)
class Meta:
nullable_fields = []
class CustomFieldFilterForm(BootstrapMixin, forms.Form):
field_groups = [
['type', 'content_types'],
['weight', 'required'],
]
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields')
)
type = forms.MultipleChoiceField(
choices=CustomFieldTypeChoices,
required=False,
widget=StaticSelect2Multiple()
)
weight = forms.IntegerField(
required=False
)
required = forms.NullBooleanField(
required=False,
widget=StaticSelect2(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
#
# Custom links
#
class CustomLinkForm(BootstrapMixin, forms.ModelForm):
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_links')
)
class Meta:
model = CustomLink
fields = '__all__'
fieldsets = (
('Custom Link', ('name', 'content_type', 'weight', 'group_name', 'button_class', 'new_window')),
('Templates', ('link_text', 'link_url')),
)
class CustomLinkCSVForm(CSVModelForm):
content_type = CSVContentTypeField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_links'),
help_text="Assigned object type"
)
class Meta:
model = CustomLink
fields = (
'name', 'content_type', 'weight', 'group_name', 'button_class', 'new_window', 'link_text', 'link_url',
)
class CustomLinkBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=CustomLink.objects.all(),
widget=forms.MultipleHiddenInput
)
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields'),
required=False
)
new_window = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
weight = forms.IntegerField(
required=False
)
button_class = forms.ChoiceField(
choices=CustomLinkButtonClassChoices,
required=False,
widget=StaticSelect2()
)
class Meta:
nullable_fields = []
class CustomLinkFilterForm(BootstrapMixin, forms.Form):
field_groups = [
['content_type'],
['weight', 'new_window'],
]
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields')
)
weight = forms.IntegerField(
required=False
)
new_window = forms.NullBooleanField(
required=False,
widget=StaticSelect2(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
#
# Export templates
#
class ExportTemplateForm(BootstrapMixin, forms.ModelForm):
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_links')
)
class Meta:
model = ExportTemplate
fields = '__all__'
fieldsets = (
('Custom Link', ('name', 'content_type', 'description')),
('Template', ('template_code',)),
('Rendering', ('mime_type', 'file_extension', 'as_attachment')),
)
class ExportTemplateCSVForm(CSVModelForm):
content_type = CSVContentTypeField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('export_templates'),
help_text="Assigned object type"
)
class Meta:
model = ExportTemplate
fields = (
'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', 'template_code',
)
class ExportTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=ExportTemplate.objects.all(),
widget=forms.MultipleHiddenInput
)
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields'),
required=False
)
description = forms.CharField(
max_length=200,
required=False
)
mime_type = forms.CharField(
max_length=50,
required=False
)
file_extension = forms.CharField(
max_length=15,
required=False
)
as_attachment = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
class Meta:
nullable_fields = ['description', 'mime_type', 'file_extension']
class ExportTemplateFilterForm(BootstrapMixin, forms.Form):
field_groups = [
['content_type', 'mime_type'],
['file_extension', 'as_attachment'],
]
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields')
)
mime_type = forms.CharField(
required=False
)
file_extension = forms.CharField(
required=False
)
as_attachment = forms.NullBooleanField(
required=False,
widget=StaticSelect2(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
#
# Webhooks
#
class WebhookForm(BootstrapMixin, forms.ModelForm):
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('webhooks')
)
class Meta:
model = Webhook
fields = '__all__'
fieldsets = (
('Webhook', ('name', 'enabled')),
('Assigned Models', ('content_types',)),
('Events', ('type_create', 'type_update', 'type_delete')),
('HTTP Request', (
'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
)),
('SSL', ('ssl_verification', 'ca_file_path')),
)
class WebhookCSVForm(CSVModelForm):
content_types = CSVMultipleContentTypeField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('webhooks'),
help_text="One or more assigned object types"
)
class Meta:
model = Webhook
fields = (
'name', 'enabled', 'content_types', 'type_create', 'type_update', 'type_delete', 'payload_url',
'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', 'ssl_verification',
'ca_file_path'
)
class WebhookBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Webhook.objects.all(),
widget=forms.MultipleHiddenInput
)
enabled = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
type_create = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
type_update = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
type_delete = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
http_method = forms.ChoiceField(
choices=WebhookHttpMethodChoices,
required=False
)
payload_url = forms.CharField(
required=False
)
ssl_verification = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
secret = forms.CharField(
required=False
)
ca_file_path = forms.CharField(
required=False
)
class Meta:
nullable_fields = ['secret', 'ca_file_path']
class WebhookFilterForm(BootstrapMixin, forms.Form):
field_groups = [
['content_types', 'http_method'],
['enabled', 'type_create', 'type_update', 'type_delete'],
]
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields')
)
http_method = forms.MultipleChoiceField(
choices=WebhookHttpMethodChoices,
required=False,
widget=StaticSelect2Multiple()
)
enabled = forms.NullBooleanField(
required=False,
widget=StaticSelect2(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
type_create = forms.NullBooleanField(
required=False,
widget=StaticSelect2(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
type_update = forms.NullBooleanField(
required=False,
widget=StaticSelect2(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
type_delete = forms.NullBooleanField(
required=False,
widget=StaticSelect2(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
#
# Custom field models
#
class CustomFieldsMixin: class CustomFieldsMixin:
""" """
Extend a Form to include custom field support. Extend a Form to include custom field support.

View File

@ -0,0 +1,51 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0060_customlink_button_class'),
]
operations = [
migrations.AddField(
model_name='customfield',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='customfield',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name='customlink',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='customlink',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name='exporttemplate',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='exporttemplate',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name='webhook',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='webhook',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
]

View File

@ -6,11 +6,12 @@ from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.core.validators import RegexValidator, ValidationError from django.core.validators import RegexValidator, ValidationError
from django.db import models from django.db import models
from django.urls import reverse
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from extras.choices import * from extras.choices import *
from extras.utils import FeatureQuery from extras.utils import FeatureQuery, extras_features
from netbox.models import BigIDModel from netbox.models import ChangeLoggedModel
from utilities.forms import ( from utilities.forms import (
CSVChoiceField, DatePicker, LaxURLField, StaticSelect2Multiple, StaticSelect2, add_blank_choice, CSVChoiceField, DatePicker, LaxURLField, StaticSelect2Multiple, StaticSelect2, add_blank_choice,
) )
@ -29,7 +30,8 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
return self.get_queryset().filter(content_types=content_type) return self.get_queryset().filter(content_types=content_type)
class CustomField(BigIDModel): @extras_features('webhooks')
class CustomField(ChangeLoggedModel):
content_types = models.ManyToManyField( content_types = models.ManyToManyField(
to=ContentType, to=ContentType,
related_name='custom_fields', related_name='custom_fields',
@ -114,6 +116,9 @@ class CustomField(BigIDModel):
def __str__(self): def __str__(self):
return self.label or self.name.replace('_', ' ').capitalize() return self.label or self.name.replace('_', ' ').capitalize()
def get_absolute_url(self):
return reverse('extras:customfield', args=[self.pk])
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)

View File

@ -36,7 +36,8 @@ __all__ = (
# Webhooks # Webhooks
# #
class Webhook(BigIDModel): @extras_features('webhooks')
class Webhook(ChangeLoggedModel):
""" """
A Webhook defines a request that will be sent to a remote application when an object is created, updated, and/or A Webhook defines a request that will be sent to a remote application when an object is created, updated, and/or
delete in NetBox. The request will contain a representation of the object, which the remote application can act on. delete in NetBox. The request will contain a representation of the object, which the remote application can act on.
@ -129,6 +130,9 @@ class Webhook(BigIDModel):
def __str__(self): def __str__(self):
return self.name return self.name
def get_absolute_url(self):
return reverse('extras:webhook', args=[self.pk])
def clean(self): def clean(self):
super().clean() super().clean()
@ -171,7 +175,8 @@ class Webhook(BigIDModel):
# Custom links # Custom links
# #
class CustomLink(BigIDModel): @extras_features('webhooks')
class CustomLink(ChangeLoggedModel):
""" """
A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template
code to be rendered with an object as context. code to be rendered with an object as context.
@ -221,12 +226,16 @@ class CustomLink(BigIDModel):
def __str__(self): def __str__(self):
return self.name return self.name
def get_absolute_url(self):
return reverse('extras:customlink', args=[self.pk])
# #
# Export templates # Export templates
# #
class ExportTemplate(BigIDModel): @extras_features('webhooks')
class ExportTemplate(ChangeLoggedModel):
content_type = models.ForeignKey( content_type = models.ForeignKey(
to=ContentType, to=ContentType,
on_delete=models.CASCADE, on_delete=models.CASCADE,
@ -269,6 +278,9 @@ class ExportTemplate(BigIDModel):
def __str__(self): def __str__(self):
return f"{self.content_type}: {self.name}" return f"{self.content_type}: {self.name}"
def get_absolute_url(self):
return reverse('extras:exporttemplate', args=[self.pk])
def clean(self): def clean(self):
super().clean() super().clean()

View File

@ -4,7 +4,7 @@ from django.conf import settings
from utilities.tables import ( from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ContentTypeColumn, ToggleColumn, BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ContentTypeColumn, ToggleColumn,
) )
from .models import ConfigContext, JournalEntry, ObjectChange, Tag, TaggedItem from .models import *
CONFIGCONTEXT_ACTIONS = """ CONFIGCONTEXT_ACTIONS = """
{% if perms.extras.change_configcontext %} {% if perms.extras.change_configcontext %}
@ -28,6 +28,96 @@ OBJECTCHANGE_REQUEST_ID = """
""" """
#
# Custom fields
#
class CustomFieldTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(
linkify=True
)
required = BooleanColumn()
class Meta(BaseTable.Meta):
model = CustomField
fields = (
'pk', 'name', 'label', 'type', 'required', 'weight', 'default', 'description', 'filter_logic', 'choices',
)
default_columns = ('pk', 'name', 'label', 'type', 'required', 'description')
#
# Custom links
#
class CustomLinkTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(
linkify=True
)
content_type = ContentTypeColumn()
new_window = BooleanColumn()
class Meta(BaseTable.Meta):
model = CustomLink
fields = (
'pk', 'name', 'content_type', 'link_text', 'link_url', 'weight', 'group_name', 'button_class', 'new_window',
)
default_columns = ('pk', 'name', 'content_type', 'group_name', 'button_class', 'new_window')
#
# Export templates
#
class ExportTemplateTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(
linkify=True
)
content_type = ContentTypeColumn()
as_attachment = BooleanColumn()
class Meta(BaseTable.Meta):
model = ExportTemplate
fields = (
'pk', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment',
)
default_columns = (
'pk', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment',
)
#
# Webhooks
#
class WebhookTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(
linkify=True
)
enabled = BooleanColumn()
type_create = BooleanColumn()
type_update = BooleanColumn()
type_delete = BooleanColumn()
class Meta(BaseTable.Meta):
model = Webhook
fields = (
'pk', 'name', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method', 'payload_url',
'secret', 'ssl_validation', 'ca_file_path',
)
default_columns = (
'pk', 'name', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method', 'payload_url',
)
#
# Tags
#
class TagTable(BaseTable): class TagTable(BaseTable):
pk = ToggleColumn() pk = ToggleColumn()
name = tables.Column( name = tables.Column(

View File

@ -6,11 +6,163 @@ from django.contrib.contenttypes.models import ContentType
from django.urls import reverse from django.urls import reverse
from dcim.models import Site from dcim.models import Site
from extras.choices import ObjectChangeActionChoices from extras.choices import *
from extras.models import ConfigContext, CustomLink, JournalEntry, ObjectChange, Tag from extras.models import *
from utilities.testing import ViewTestCases, TestCase from utilities.testing import ViewTestCases, TestCase
class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = CustomField
@classmethod
def setUpTestData(cls):
site_ct = ContentType.objects.get_for_model(Site)
custom_fields = (
CustomField(name='field1', label='Field 1', type=CustomFieldTypeChoices.TYPE_TEXT),
CustomField(name='field2', label='Field 2', type=CustomFieldTypeChoices.TYPE_TEXT),
CustomField(name='field3', label='Field 3', type=CustomFieldTypeChoices.TYPE_TEXT),
)
for customfield in custom_fields:
customfield.save()
customfield.content_types.add(site_ct)
cls.form_data = {
'name': 'field_x',
'label': 'Field X',
'type': 'text',
'content_types': [site_ct.pk],
'filter_logic': CustomFieldFilterLogicChoices.FILTER_EXACT,
'default': None,
'weight': 200,
'required': True,
}
cls.csv_data = (
"name,label,type,content_types,weight,filter_logic",
"field4,Field 4,text,dcim.site,100,exact",
"field5,Field 5,text,dcim.site,100,exact",
"field6,Field 6,text,dcim.site,100,exact",
)
cls.bulk_edit_data = {
'required': True,
'weight': 200,
}
class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = CustomLink
@classmethod
def setUpTestData(cls):
site_ct = ContentType.objects.get_for_model(Site)
CustomLink.objects.bulk_create((
CustomLink(name='Custom Link 1', content_type=site_ct, link_text='Link 1', link_url='http://example.com/?1'),
CustomLink(name='Custom Link 2', content_type=site_ct, link_text='Link 2', link_url='http://example.com/?2'),
CustomLink(name='Custom Link 3', content_type=site_ct, link_text='Link 3', link_url='http://example.com/?3'),
))
cls.form_data = {
'name': 'Custom Link X',
'content_type': site_ct.pk,
'weight': 100,
'button_class': CustomLinkButtonClassChoices.CLASS_DEFAULT,
'link_text': 'Link X',
'link_url': 'http://example.com/?x'
}
cls.csv_data = (
"name,content_type,weight,button_class,link_text,link_url",
"Custom Link 4,dcim.site,100,primary,Link 4,http://exmaple.com/?4",
"Custom Link 5,dcim.site,100,primary,Link 5,http://exmaple.com/?5",
"Custom Link 6,dcim.site,100,primary,Link 6,http://exmaple.com/?6",
)
cls.bulk_edit_data = {
'button_class': CustomLinkButtonClassChoices.CLASS_INFO,
'weight': 200,
}
class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = ExportTemplate
@classmethod
def setUpTestData(cls):
site_ct = ContentType.objects.get_for_model(Site)
TEMPLATE_CODE = """{% for object in queryset %}{{ object }}{% endfor %}"""
ExportTemplate.objects.bulk_create((
ExportTemplate(name='Export Template 1', content_type=site_ct, template_code=TEMPLATE_CODE),
ExportTemplate(name='Export Template 2', content_type=site_ct, template_code=TEMPLATE_CODE),
ExportTemplate(name='Export Template 3', content_type=site_ct, template_code=TEMPLATE_CODE),
))
cls.form_data = {
'name': 'Export Template X',
'content_type': site_ct.pk,
'template_code': TEMPLATE_CODE,
}
cls.csv_data = (
"name,content_type,template_code",
f"Export Template 4,dcim.site,{TEMPLATE_CODE}",
f"Export Template 5,dcim.site,{TEMPLATE_CODE}",
f"Export Template 6,dcim.site,{TEMPLATE_CODE}",
)
cls.bulk_edit_data = {
'mime_type': 'text/html',
'file_extension': 'html',
'as_attachment': True,
}
class WebhookTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Webhook
@classmethod
def setUpTestData(cls):
site_ct = ContentType.objects.get_for_model(Site)
webhooks = (
Webhook(name='Webhook 1', payload_url='http://example.com/?1', type_create=True, http_method='POST'),
Webhook(name='Webhook 2', payload_url='http://example.com/?2', type_create=True, http_method='POST'),
Webhook(name='Webhook 3', payload_url='http://example.com/?3', type_create=True, http_method='POST'),
)
for webhook in webhooks:
webhook.save()
webhook.content_types.add(site_ct)
cls.form_data = {
'name': 'Webhook X',
'content_types': [site_ct.pk],
'type_create': False,
'type_update': True,
'type_delete': True,
'payload_url': 'http://example.com/?x',
'http_method': 'GET',
'http_content_type': 'application/foo',
}
cls.csv_data = (
"name,content_types,type_create,payload_url,http_method,http_content_type",
"Webhook 4,dcim.site,True,http://example.com/?4,GET,application/json",
"Webhook 5,dcim.site,True,http://example.com/?5,GET,application/json",
"Webhook 6,dcim.site,True,http://example.com/?6,GET,application/json",
)
cls.bulk_edit_data = {
'enabled': False,
'type_create': False,
'type_update': True,
'type_delete': True,
'http_method': 'GET',
}
class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase): class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = Tag model = Tag

View File

@ -1,12 +1,59 @@
from django.urls import path from django.urls import path
from extras import views from extras import models, views
from extras.models import ConfigContext, JournalEntry, Tag
app_name = 'extras' app_name = 'extras'
urlpatterns = [ urlpatterns = [
# Custom fields
path('custom-fields/', views.CustomFieldListView.as_view(), name='customfield_list'),
path('custom-fields/add/', views.CustomFieldEditView.as_view(), name='customfield_add'),
path('custom-fields/import/', views.CustomFieldBulkImportView.as_view(), name='customfield_import'),
path('custom-fields/edit/', views.CustomFieldBulkEditView.as_view(), name='customfield_bulk_edit'),
path('custom-fields/delete/', views.CustomFieldBulkDeleteView.as_view(), name='customfield_bulk_delete'),
path('custom-fields/<int:pk>/', views.CustomFieldView.as_view(), name='customfield'),
path('custom-fields/<int:pk>/edit/', views.CustomFieldEditView.as_view(), name='customfield_edit'),
path('custom-fields/<int:pk>/delete/', views.CustomFieldDeleteView.as_view(), name='customfield_delete'),
path('custom-fields/<int:pk>/changelog/', views.ObjectChangeLogView.as_view(), name='customfield_changelog',
kwargs={'model': models.CustomField}),
# Custom links
path('custom-links/', views.CustomLinkListView.as_view(), name='customlink_list'),
path('custom-links/add/', views.CustomLinkEditView.as_view(), name='customlink_add'),
path('custom-links/import/', views.CustomLinkBulkImportView.as_view(), name='customlink_import'),
path('custom-links/edit/', views.CustomLinkBulkEditView.as_view(), name='customlink_bulk_edit'),
path('custom-links/delete/', views.CustomLinkBulkDeleteView.as_view(), name='customlink_bulk_delete'),
path('custom-links/<int:pk>/', views.CustomLinkView.as_view(), name='customlink'),
path('custom-links/<int:pk>/edit/', views.CustomLinkEditView.as_view(), name='customlink_edit'),
path('custom-links/<int:pk>/delete/', views.CustomLinkDeleteView.as_view(), name='customlink_delete'),
path('custom-links/<int:pk>/changelog/', views.ObjectChangeLogView.as_view(), name='customlink_changelog',
kwargs={'model': models.CustomLink}),
# Export templates
path('export-templates/', views.ExportTemplateListView.as_view(), name='exporttemplate_list'),
path('export-templates/add/', views.ExportTemplateEditView.as_view(), name='exporttemplate_add'),
path('export-templates/import/', views.ExportTemplateBulkImportView.as_view(), name='exporttemplate_import'),
path('export-templates/edit/', views.ExportTemplateBulkEditView.as_view(), name='exporttemplate_bulk_edit'),
path('export-templates/delete/', views.ExportTemplateBulkDeleteView.as_view(), name='exporttemplate_bulk_delete'),
path('export-templates/<int:pk>/', views.ExportTemplateView.as_view(), name='exporttemplate'),
path('export-templates/<int:pk>/edit/', views.ExportTemplateEditView.as_view(), name='exporttemplate_edit'),
path('export-templates/<int:pk>/delete/', views.ExportTemplateDeleteView.as_view(), name='exporttemplate_delete'),
path('export-templates/<int:pk>/changelog/', views.ObjectChangeLogView.as_view(), name='exporttemplate_changelog',
kwargs={'model': models.ExportTemplate}),
# Webhooks
path('webhooks/', views.WebhookListView.as_view(), name='webhook_list'),
path('webhooks/add/', views.WebhookEditView.as_view(), name='webhook_add'),
path('webhooks/import/', views.WebhookBulkImportView.as_view(), name='webhook_import'),
path('webhooks/edit/', views.WebhookBulkEditView.as_view(), name='webhook_bulk_edit'),
path('webhooks/delete/', views.WebhookBulkDeleteView.as_view(), name='webhook_bulk_delete'),
path('webhooks/<int:pk>/', views.WebhookView.as_view(), name='webhook'),
path('webhooks/<int:pk>/edit/', views.WebhookEditView.as_view(), name='webhook_edit'),
path('webhooks/<int:pk>/delete/', views.WebhookDeleteView.as_view(), name='webhook_delete'),
path('webhooks/<int:pk>/changelog/', views.ObjectChangeLogView.as_view(), name='webhook_changelog',
kwargs={'model': models.Webhook}),
# Tags # Tags
path('tags/', views.TagListView.as_view(), name='tag_list'), path('tags/', views.TagListView.as_view(), name='tag_list'),
path('tags/add/', views.TagEditView.as_view(), name='tag_add'), path('tags/add/', views.TagEditView.as_view(), name='tag_add'),
@ -16,7 +63,8 @@ urlpatterns = [
path('tags/<int:pk>/', views.TagView.as_view(), name='tag'), path('tags/<int:pk>/', views.TagView.as_view(), name='tag'),
path('tags/<int:pk>/edit/', views.TagEditView.as_view(), name='tag_edit'), path('tags/<int:pk>/edit/', views.TagEditView.as_view(), name='tag_edit'),
path('tags/<int:pk>/delete/', views.TagDeleteView.as_view(), name='tag_delete'), path('tags/<int:pk>/delete/', views.TagDeleteView.as_view(), name='tag_delete'),
path('tags/<int:pk>/changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}), path('tags/<int:pk>/changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog',
kwargs={'model': models.Tag}),
# Config contexts # Config contexts
path('config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'), path('config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'),
@ -26,7 +74,8 @@ urlpatterns = [
path('config-contexts/<int:pk>/', views.ConfigContextView.as_view(), name='configcontext'), path('config-contexts/<int:pk>/', views.ConfigContextView.as_view(), name='configcontext'),
path('config-contexts/<int:pk>/edit/', views.ConfigContextEditView.as_view(), name='configcontext_edit'), path('config-contexts/<int:pk>/edit/', views.ConfigContextEditView.as_view(), name='configcontext_edit'),
path('config-contexts/<int:pk>/delete/', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'), path('config-contexts/<int:pk>/delete/', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'),
path('config-contexts/<int:pk>/changelog/', views.ObjectChangeLogView.as_view(), name='configcontext_changelog', kwargs={'model': ConfigContext}), path('config-contexts/<int:pk>/changelog/', views.ObjectChangeLogView.as_view(), name='configcontext_changelog',
kwargs={'model': models.ConfigContext}),
# Image attachments # Image attachments
path('image-attachments/<int:pk>/edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'), path('image-attachments/<int:pk>/edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
@ -40,7 +89,8 @@ urlpatterns = [
path('journal-entries/<int:pk>/', views.JournalEntryView.as_view(), name='journalentry'), path('journal-entries/<int:pk>/', views.JournalEntryView.as_view(), name='journalentry'),
path('journal-entries/<int:pk>/edit/', views.JournalEntryEditView.as_view(), name='journalentry_edit'), path('journal-entries/<int:pk>/edit/', views.JournalEntryEditView.as_view(), name='journalentry_edit'),
path('journal-entries/<int:pk>/delete/', views.JournalEntryDeleteView.as_view(), name='journalentry_delete'), path('journal-entries/<int:pk>/delete/', views.JournalEntryDeleteView.as_view(), name='journalentry_delete'),
path('journal-entries/<int:pk>/changelog/', views.ObjectChangeLogView.as_view(), name='journalentry_changelog', kwargs={'model': JournalEntry}), path('journal-entries/<int:pk>/changelog/', views.ObjectChangeLogView.as_view(), name='journalentry_changelog',
kwargs={'model': models.JournalEntry}),
# Change logging # Change logging
path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'), path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),

View File

@ -15,11 +15,183 @@ from utilities.utils import copy_safe_request, count_related, shallow_compare_di
from utilities.views import ContentTypePermissionRequiredMixin from utilities.views import ContentTypePermissionRequiredMixin
from . import filtersets, forms, tables from . import filtersets, forms, tables
from .choices import JobResultStatusChoices from .choices import JobResultStatusChoices
from .models import ConfigContext, ImageAttachment, JournalEntry, ObjectChange, JobResult, Tag, TaggedItem from .models import *
from .reports import get_report, get_reports, run_report from .reports import get_report, get_reports, run_report
from .scripts import get_scripts, run_script from .scripts import get_scripts, run_script
#
# Custom fields
#
class CustomFieldListView(generic.ObjectListView):
queryset = CustomField.objects.all()
filterset = filtersets.CustomFieldFilterSet
filterset_form = forms.CustomFieldFilterForm
table = tables.CustomFieldTable
class CustomFieldView(generic.ObjectView):
queryset = CustomField.objects.all()
class CustomFieldEditView(generic.ObjectEditView):
queryset = CustomField.objects.all()
model_form = forms.CustomFieldForm
class CustomFieldDeleteView(generic.ObjectDeleteView):
queryset = CustomField.objects.all()
class CustomFieldBulkImportView(generic.BulkImportView):
queryset = CustomField.objects.all()
model_form = forms.CustomFieldCSVForm
table = tables.CustomFieldTable
class CustomFieldBulkEditView(generic.BulkEditView):
queryset = CustomField.objects.all()
filterset = filtersets.CustomFieldFilterSet
table = tables.CustomFieldTable
form = forms.CustomFieldBulkEditForm
class CustomFieldBulkDeleteView(generic.BulkDeleteView):
queryset = CustomField.objects.all()
filterset = filtersets.CustomFieldFilterSet
table = tables.CustomFieldTable
#
# Custom links
#
class CustomLinkListView(generic.ObjectListView):
queryset = CustomLink.objects.all()
filterset = filtersets.CustomLinkFilterSet
filterset_form = forms.CustomLinkFilterForm
table = tables.CustomLinkTable
class CustomLinkView(generic.ObjectView):
queryset = CustomLink.objects.all()
class CustomLinkEditView(generic.ObjectEditView):
queryset = CustomLink.objects.all()
model_form = forms.CustomLinkForm
class CustomLinkDeleteView(generic.ObjectDeleteView):
queryset = CustomLink.objects.all()
class CustomLinkBulkImportView(generic.BulkImportView):
queryset = CustomLink.objects.all()
model_form = forms.CustomLinkCSVForm
table = tables.CustomLinkTable
class CustomLinkBulkEditView(generic.BulkEditView):
queryset = CustomLink.objects.all()
filterset = filtersets.CustomLinkFilterSet
table = tables.CustomLinkTable
form = forms.CustomLinkBulkEditForm
class CustomLinkBulkDeleteView(generic.BulkDeleteView):
queryset = CustomLink.objects.all()
filterset = filtersets.CustomLinkFilterSet
table = tables.CustomLinkTable
#
# Export templates
#
class ExportTemplateListView(generic.ObjectListView):
queryset = ExportTemplate.objects.all()
filterset = filtersets.ExportTemplateFilterSet
filterset_form = forms.ExportTemplateFilterForm
table = tables.ExportTemplateTable
class ExportTemplateView(generic.ObjectView):
queryset = ExportTemplate.objects.all()
class ExportTemplateEditView(generic.ObjectEditView):
queryset = ExportTemplate.objects.all()
model_form = forms.ExportTemplateForm
class ExportTemplateDeleteView(generic.ObjectDeleteView):
queryset = ExportTemplate.objects.all()
class ExportTemplateBulkImportView(generic.BulkImportView):
queryset = ExportTemplate.objects.all()
model_form = forms.ExportTemplateCSVForm
table = tables.ExportTemplateTable
class ExportTemplateBulkEditView(generic.BulkEditView):
queryset = ExportTemplate.objects.all()
filterset = filtersets.ExportTemplateFilterSet
table = tables.ExportTemplateTable
form = forms.ExportTemplateBulkEditForm
class ExportTemplateBulkDeleteView(generic.BulkDeleteView):
queryset = ExportTemplate.objects.all()
filterset = filtersets.ExportTemplateFilterSet
table = tables.ExportTemplateTable
#
# Webhooks
#
class WebhookListView(generic.ObjectListView):
queryset = Webhook.objects.all()
filterset = filtersets.WebhookFilterSet
filterset_form = forms.WebhookFilterForm
table = tables.WebhookTable
class WebhookView(generic.ObjectView):
queryset = Webhook.objects.all()
class WebhookEditView(generic.ObjectEditView):
queryset = Webhook.objects.all()
model_form = forms.WebhookForm
class WebhookDeleteView(generic.ObjectDeleteView):
queryset = Webhook.objects.all()
class WebhookBulkImportView(generic.BulkImportView):
queryset = Webhook.objects.all()
model_form = forms.WebhookCSVForm
table = tables.WebhookTable
class WebhookBulkEditView(generic.BulkEditView):
queryset = Webhook.objects.all()
filterset = filtersets.WebhookFilterSet
table = tables.WebhookTable
form = forms.WebhookBulkEditForm
class WebhookBulkDeleteView(generic.BulkDeleteView):
queryset = Webhook.objects.all()
filterset = filtersets.WebhookFilterSet
table = tables.WebhookTable
# #
# Tags # Tags
# #

View File

@ -0,0 +1,120 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% block breadcrumbs %}
<li class="breadcrumb-item"><a href="{% url 'extras:customfield_list' %}">Custom Fields</a></li>
<li class="breadcrumb-item">{{ object }}</li>
{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
Custom Field
</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Name</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">Label</th>
<td>{{ object.label|placeholder }}</td>
</tr>
<tr>
<th scope="row">Type</th>
<td>{{ object.get_type_display }}</td>
</tr>
<tr>
<th scope="row">Description</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">Required</th>
<td>
{% if object.required %}
<i class="mdi mdi-check-bold text-success" title="Yes"></i>
{% else %}
<i class="mdi mdi-close-thick text-danger" title="No"></i>
{% endif %}
</td>
</tr>
<tr>
<th scope="row">Weight</th>
<td>{{ object.weight }}</td>
</tr>
</table>
</div>
</div>
<div class="card">
<h5 class="card-header">
Values
</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Default Value</th>
<td>{{ object.default }}</td>
</tr>
<tr>
<th scope="row">Choices</th>
<td>{{ object.choices|placeholder }}</td>
</tr>
<tr>
<th scope="row">Filter Logic</th>
<td>{{ object.get_filter_logic_display }}</td>
</tr>
</table>
</div>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
Assigned Models
</h5>
<div class="card-body">
<table class="table table-hover attr-table">
{% for ct in object.content_types.all %}
<tr>
<td>{{ ct }}</td>
</tr>
{% endfor %}
</table>
</div>
</div>
<div class="card">
<h5 class="card-header">
Validation Rules
</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Minimum Value</th>
<td>{{ object.validation_minimum|placeholder }}</td>
</tr>
<tr>
<th scope="row">Maximum Value</th>
<td>{{ object.validation_maximum|placeholder }}</td>
</tr>
<tr>
<th scope="row">Regular Expression</th>
<td>
{% if object.validation_regex %}
<code>{{ object.validation_regex }}</code>
{% else %}
&mdash;
{% endif %}
</td>
</tr>
</table>
</div>
</div>
{% plugin_right_page object %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,74 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% block breadcrumbs %}
<li class="breadcrumb-item"><a href="{% url 'extras:customlink_list' %}">Custom Links</a></li>
<li class="breadcrumb-item">{{ object }}</li>
{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
Custom Link
</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Content Type</th>
<td>{{ object.content_type }}</td>
</tr>
<tr>
<th scope="row">Name</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">Group Name</th>
<td>{{ object.group_name|placeholder }}</td>
</tr>
<tr>
<th scope="row">Weight</th>
<td>{{ object.weight }}</td>
</tr>
<tr>
<th scope="row">Button Class</th>
<td>{{ object.get_button_class_display }}</td>
</tr>
<tr>
<th scope="row">New Window</th>
<td>
{% if object.new_window %}
<i class="mdi mdi-check-bold text-success" title="Yes"></i>
{% else %}
<i class="mdi mdi-close-thick text-danger" title="No"></i>
{% endif %}
</td>
</tr>
</table>
</div>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
Link Text
</h5>
<div class="card-body">
<pre>{{ object.link_text }}</pre>
</div>
</div>
<div class="card">
<h5 class="card-header">
Link URL
</h5>
<div class="card-body">
<pre>{{ object.link_url }}</pre>
</div>
</div>
{% plugin_right_page object %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,66 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% block breadcrumbs %}
<li class="breadcrumb-item"><a href="{% url 'extras:exporttemplate_list' %}">Export Templates</a></li>
<li class="breadcrumb-item">{{ object }}</li>
{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
Export Template
</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Content Type</th>
<td>{{ object.content_type }}</td>
</tr>
<tr>
<th scope="row">Name</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">Description</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">MIME Type</th>
<td>{{ object.mime_type|placeholder }}</td>
</tr>
<tr>
<th scope="row">File Extension</th>
<td>{{ object.file_extension|placeholder }}</td>
</tr>
<tr>
<th scope="row">Attachment</th>
<td>
{% if object.as_attachment %}
<i class="mdi mdi-check-bold text-success" title="Yes"></i>
{% else %}
<i class="mdi mdi-close-thick text-danger" title="No"></i>
{% endif %}
</td>
</tr>
</table>
</div>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
Template
</h5>
<div class="card-body">
<pre>{{ object.template_code }}</pre>
</div>
</div>
{% plugin_right_page object %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,165 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% block breadcrumbs %}
<li class="breadcrumb-item"><a href="{% url 'extras:webhook_list' %}">Webhooks</a></li>
<li class="breadcrumb-item">{{ object }}</li>
{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
Webhook
</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Name</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">Enabled</th>
<td>
{% if object.enabled %}
<i class="mdi mdi-check-bold text-success" title="Yes"></i>
{% else %}
<i class="mdi mdi-close-thick text-danger" title="No"></i>
{% endif %}
</td>
</tr>
</table>
</div>
</div>
<div class="card">
<h5 class="card-header">
Events
</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Create</th>
<td>
{% if object.type_create %}
<i class="mdi mdi-check-bold text-success" title="Yes"></i>
{% else %}
<i class="mdi mdi-close-thick text-danger" title="No"></i>
{% endif %}
</td>
</tr>
<tr>
<th scope="row">Update</th>
<td>
{% if object.type_create %}
<i class="mdi mdi-check-bold text-success" title="Yes"></i>
{% else %}
<i class="mdi mdi-close-thick text-danger" title="No"></i>
{% endif %}
</td>
</tr>
<tr>
<th scope="row">Delete</th>
<td>
{% if object.type_create %}
<i class="mdi mdi-check-bold text-success" title="Yes"></i>
{% else %}
<i class="mdi mdi-close-thick text-danger" title="No"></i>
{% endif %}
</td>
</tr>
</table>
</div>
</div>
<div class="card">
<h5 class="card-header">
HTTP Request
</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">HTTP Method</th>
<td>{{ object.get_http_method_display }}</td>
</tr>
<tr>
<th scope="row">Payload URL</th>
<td><code>{{ object.payload_url }}</code></td>
</tr>
<tr>
<th scope="row">HTTP Content Type</th>
<td>{{ object.http_content_type }}</td>
</tr>
<tr>
<th scope="row">Secret</th>
<td>{{ object.secret|placeholder }}</td>
</tr>
</table>
</div>
</div>
<div class="card">
<h5 class="card-header">
SSL
</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">SSL Verification</th>
<td>
{% if object.ssl_verification %}
<i class="mdi mdi-check-bold text-success" title="Yes"></i>
{% else %}
<i class="mdi mdi-close-thick text-danger" title="No"></i>
{% endif %}
</td>
</tr>
<tr>
<th scope="row">CA File Path</th>
<td>
{% if object.ca_file_path %}
<code>{{ object.ca_file_path }}</code>
{% else %}
&mdash
{% endif %}
</td>
</tr>
</table>
</div>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
Assigned Models
</h5>
<div class="card-body">
<table class="table table-hover attr-table">
{% for ct in object.content_types.all %}
<tr>
<td>{{ ct }}</td>
</tr>
{% endfor %}
</table>
</div>
</div>
<div class="card">
<h5 class="card-header">
Additional Headers
</h5>
<div class="card-body">
<pre>{{ object.additional_headers }}</pre>
</div>
</div>
<div class="card">
<h5 class="card-header">
Body Template
</h5>
<div class="card-body">
<pre>{{ object.body_template }}</pre>
</div>
</div>
{% plugin_right_page object %}
</div>
</div>
{% endblock %}

View File

@ -6,8 +6,9 @@ from io import StringIO
import django_filters import django_filters
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
from django.db.models import Count from django.db.models import Count, Q
from django.forms import BoundField from django.forms import BoundField
from django.forms.fields import JSONField as _JSONField, InvalidJSONInput from django.forms.fields import JSONField as _JSONField, InvalidJSONInput
from django.urls import reverse from django.urls import reverse
@ -28,6 +29,7 @@ __all__ = (
'CSVContentTypeField', 'CSVContentTypeField',
'CSVDataField', 'CSVDataField',
'CSVModelChoiceField', 'CSVModelChoiceField',
'CSVMultipleContentTypeField',
'CSVTypedChoiceField', 'CSVTypedChoiceField',
'DynamicModelChoiceField', 'DynamicModelChoiceField',
'DynamicModelMultipleChoiceField', 'DynamicModelMultipleChoiceField',
@ -281,6 +283,20 @@ class CSVContentTypeField(CSVModelChoiceField):
raise forms.ValidationError(f'Invalid object type') raise forms.ValidationError(f'Invalid object type')
class CSVMultipleContentTypeField(forms.ModelMultipleChoiceField):
STATIC_CHOICES = True
# TODO: Improve validation of selected ContentTypes
def prepare_value(self, value):
if type(value) is str:
ct_filter = Q()
for name in value.split(','):
app_label, model = name.split('.')
ct_filter |= Q(app_label=app_label, model=model)
return list(ContentType.objects.filter(ct_filter).values_list('pk', flat=True))
return super().prepare_value(value)
# #
# Expansion fields # Expansion fields
# #

View File

@ -88,7 +88,7 @@ def export_button(context, content_type=None):
user = context['request'].user user = context['request'].user
export_templates = ExportTemplate.objects.restrict(user, 'view').filter(content_type=content_type) export_templates = ExportTemplate.objects.restrict(user, 'view').filter(content_type=content_type)
if user.is_staff and user.has_perm('extras.add_exporttemplate'): if user.is_staff and user.has_perm('extras.add_exporttemplate'):
add_exporttemplate_link = f"{reverse('admin:extras_exporttemplate_add')}?content_type={content_type.pk}" add_exporttemplate_link = f"{reverse('extras:exporttemplate_add')}?content_type={content_type.pk}"
else: else:
export_templates = [] export_templates = []

View File

@ -287,6 +287,19 @@ OTHER_MENU = Menu(
add_url=None, import_url=None), add_url=None, import_url=None),
MenuItem(label="Journal Entries", MenuItem(label="Journal Entries",
url="extras:journalentry_list", add_url=None, import_url=None), url="extras:journalentry_list", add_url=None, import_url=None),
MenuItem(label="Webhooks", url="extras:webhook_list",
add_url="extras:webhook_add", import_url="extras:webhook_import"),
),
),
MenuGroup(
label="Customization",
items=(
MenuItem(label="Custom Fields", url="extras:customfield_list",
add_url="extras:customfield_add", import_url="extras:customfield_import"),
MenuItem(label="Custom Links", url="extras:customlink_list",
add_url="extras:customlink_add", import_url="extras:customlink_import"),
MenuItem(label="Export Templates", url="extras:exporttemplate_list",
add_url="extras:exporttemplate_add", import_url="extras:exporttemplate_import"),
), ),
), ),
MenuGroup( MenuGroup(

View File

@ -109,12 +109,12 @@ class ModelTestCase(TestCase):
# Handle ManyToManyFields # Handle ManyToManyFields
if value and type(field) in (ManyToManyField, TaggableManager): if value and type(field) in (ManyToManyField, TaggableManager):
if field.related_model is ContentType: if field.related_model is ContentType and api:
model_dict[key] = sorted([f'{ct.app_label}.{ct.model}' for ct in value]) model_dict[key] = sorted([f'{ct.app_label}.{ct.model}' for ct in value])
else: else:
model_dict[key] = sorted([obj.pk for obj in value]) model_dict[key] = sorted([obj.pk for obj in value])
if api: elif api:
# Replace ContentType numeric IDs with <app_label>.<model> # Replace ContentType numeric IDs with <app_label>.<model>
if type(getattr(instance, key)) is ContentType: if type(getattr(instance, key)) is ContentType: