mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-22 19:48:45 -06:00
Compare commits
2 Commits
7f2b744a53
...
4e0e4598b0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e0e4598b0 | ||
|
|
cebc56e5cc |
@@ -24,10 +24,10 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = ImageAttachment
|
model = ImageAttachment
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'object_type', 'object_id', 'parent', 'name', 'image',
|
'id', 'url', 'display', 'object_type', 'object_id', 'parent', 'name', 'image', 'description',
|
||||||
'image_height', 'image_width', 'created', 'last_updated',
|
'image_height', 'image_width', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
brief_fields = ('id', 'url', 'display', 'name', 'image')
|
brief_fields = ('id', 'url', 'display', 'name', 'image', 'description')
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
|
|
||||||
|
|||||||
@@ -451,12 +451,15 @@ class ImageAttachmentFilterSet(ChangeLoggedModelFilterSet):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ImageAttachment
|
model = ImageAttachment
|
||||||
fields = ('id', 'object_type_id', 'object_id', 'name', 'image_width', 'image_height')
|
fields = ('id', 'object_type_id', 'object_id', 'name', 'description', 'image_width', 'image_height')
|
||||||
|
|
||||||
def search(self, queryset, name, value):
|
def search(self, queryset, name, value):
|
||||||
if not value.strip():
|
if not value.strip():
|
||||||
return queryset
|
return queryset
|
||||||
return queryset.filter(name__icontains=value)
|
return queryset.filter(
|
||||||
|
Q(name__icontains=value) |
|
||||||
|
Q(description__icontains=value)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class JournalEntryFilterSet(NetBoxModelFilterSet):
|
class JournalEntryFilterSet(NetBoxModelFilterSet):
|
||||||
|
|||||||
@@ -744,14 +744,17 @@ class ConfigTemplateForm(SyncedDataMixin, forms.ModelForm):
|
|||||||
|
|
||||||
class ImageAttachmentForm(forms.ModelForm):
|
class ImageAttachmentForm(forms.ModelForm):
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet(ObjectAttribute('parent'), 'name', 'image'),
|
FieldSet(ObjectAttribute('parent'), 'image', 'name', 'description'),
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ImageAttachment
|
model = ImageAttachment
|
||||||
fields = [
|
fields = [
|
||||||
'name', 'image',
|
'image', 'name', 'description',
|
||||||
]
|
]
|
||||||
|
help_texts = {
|
||||||
|
'name': _("If no name is specified, the file name will be used.")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class JournalEntryForm(NetBoxModelForm):
|
class JournalEntryForm(NetBoxModelForm):
|
||||||
|
|||||||
16
netbox/extras/migrations/0130_imageattachment_description.py
Normal file
16
netbox/extras/migrations/0130_imageattachment_description.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('extras', '0129_fix_script_paths'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='imageattachment',
|
||||||
|
name='description',
|
||||||
|
field=models.CharField(blank=True, max_length=200),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -678,6 +679,11 @@ class ImageAttachment(ChangeLoggedModel):
|
|||||||
max_length=50,
|
max_length=50,
|
||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
|
description = models.CharField(
|
||||||
|
verbose_name=_('description'),
|
||||||
|
max_length=200,
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
|
|
||||||
objects = RestrictedQuerySet.as_manager()
|
objects = RestrictedQuerySet.as_manager()
|
||||||
|
|
||||||
@@ -692,10 +698,10 @@ class ImageAttachment(ChangeLoggedModel):
|
|||||||
verbose_name_plural = _('image attachments')
|
verbose_name_plural = _('image attachments')
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
if self.name:
|
return self.name or self.filename
|
||||||
return self.name
|
|
||||||
filename = self.image.name.rsplit('/', 1)[-1]
|
def get_absolute_url(self):
|
||||||
return filename.split('_', 2)[2]
|
return reverse('extras:imageattachment', args=[self.pk])
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
@@ -719,6 +725,10 @@ class ImageAttachment(ChangeLoggedModel):
|
|||||||
# before the request finishes. (For example, to display a message indicating the ImageAttachment was deleted.)
|
# before the request finishes. (For example, to display a message indicating the ImageAttachment was deleted.)
|
||||||
self.image.name = _name
|
self.image.name = _name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def filename(self):
|
||||||
|
return os.path.basename(self.image.name).split('_', 2)[2]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def size(self):
|
def size(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ class CustomFieldIndex(SearchIndex):
|
|||||||
display_attrs = ('description',)
|
display_attrs = ('description',)
|
||||||
|
|
||||||
|
|
||||||
|
@register_search
|
||||||
|
class ImageAttachmentIndex(SearchIndex):
|
||||||
|
model = models.ImageAttachment
|
||||||
|
fields = (
|
||||||
|
('name', 100),
|
||||||
|
('description', 500),
|
||||||
|
)
|
||||||
|
display_attrs = ('description',)
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
class JournalEntryIndex(SearchIndex):
|
class JournalEntryIndex(SearchIndex):
|
||||||
model = models.JournalEntry
|
model = models.JournalEntry
|
||||||
|
|||||||
@@ -249,10 +249,10 @@ class ImageAttachmentTable(NetBoxTable):
|
|||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = ImageAttachment
|
model = ImageAttachment
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'object_type', 'parent', 'image', 'name', 'image_height', 'image_width', 'size', 'created',
|
'pk', 'object_type', 'parent', 'image', 'name', 'description', 'image_height', 'image_width', 'size',
|
||||||
'last_updated',
|
'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = ('object_type', 'parent', 'image', 'name', 'size', 'created')
|
default_columns = ('object_type', 'parent', 'image', 'name', 'description', 'size', 'created')
|
||||||
|
|
||||||
|
|
||||||
class SavedFilterTable(NetBoxTable):
|
class SavedFilterTable(NetBoxTable):
|
||||||
|
|||||||
@@ -579,7 +579,7 @@ class ImageAttachmentTest(
|
|||||||
APIViewTestCases.GraphQLTestCase
|
APIViewTestCases.GraphQLTestCase
|
||||||
):
|
):
|
||||||
model = ImageAttachment
|
model = ImageAttachment
|
||||||
brief_fields = ['display', 'id', 'image', 'name', 'url']
|
brief_fields = ['description', 'display', 'id', 'image', 'name', 'url']
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
|
|||||||
@@ -1040,6 +1040,11 @@ class ImageAttachmentListView(generic.ObjectListView):
|
|||||||
actions = (BulkExport,)
|
actions = (BulkExport,)
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(ImageAttachment)
|
||||||
|
class ImageAttachmentView(generic.ObjectView):
|
||||||
|
queryset = ImageAttachment.objects.all()
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(ImageAttachment, 'add', detail=False)
|
@register_model_view(ImageAttachment, 'add', detail=False)
|
||||||
@register_model_view(ImageAttachment, 'edit')
|
@register_model_view(ImageAttachment, 'edit')
|
||||||
class ImageAttachmentEditView(generic.ObjectEditView):
|
class ImageAttachmentEditView(generic.ObjectEditView):
|
||||||
@@ -1053,9 +1058,6 @@ class ImageAttachmentEditView(generic.ObjectEditView):
|
|||||||
instance.parent = get_object_or_404(object_type.model_class(), pk=request.GET.get('object_id'))
|
instance.parent = get_object_or_404(object_type.model_class(), pk=request.GET.get('object_id'))
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
def get_return_url(self, request, obj=None):
|
|
||||||
return obj.parent.get_absolute_url() if obj else super().get_return_url(request)
|
|
||||||
|
|
||||||
def get_extra_addanother_params(self, request):
|
def get_extra_addanother_params(self, request):
|
||||||
return {
|
return {
|
||||||
'object_type': request.GET.get('object_type'),
|
'object_type': request.GET.get('object_type'),
|
||||||
@@ -1067,9 +1069,6 @@ class ImageAttachmentEditView(generic.ObjectEditView):
|
|||||||
class ImageAttachmentDeleteView(generic.ObjectDeleteView):
|
class ImageAttachmentDeleteView(generic.ObjectDeleteView):
|
||||||
queryset = ImageAttachment.objects.all()
|
queryset = ImageAttachment.objects.all()
|
||||||
|
|
||||||
def get_return_url(self, request, obj=None):
|
|
||||||
return obj.parent.get_absolute_url() if obj else super().get_return_url(request)
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Journal entries
|
# Journal entries
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from core.models import ObjectType
|
from core.models import ObjectType
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from extras.models import CustomField, Tag
|
from extras.models import CustomField, Tag
|
||||||
from utilities.forms import CSVModelForm
|
from utilities.forms import BulkEditForm, CSVModelForm
|
||||||
from utilities.forms.fields import CSVModelMultipleChoiceField, DynamicModelMultipleChoiceField
|
from utilities.forms.fields import CSVModelMultipleChoiceField, DynamicModelMultipleChoiceField
|
||||||
from utilities.forms.mixins import CheckLastUpdatedMixin
|
from utilities.forms.mixins import CheckLastUpdatedMixin
|
||||||
from .mixins import CustomFieldsMixin, SavedFiltersMixin, TagsMixin
|
from .mixins import CustomFieldsMixin, SavedFiltersMixin, TagsMixin
|
||||||
@@ -100,7 +100,7 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
|
|||||||
return customfield.to_form_field(for_csv_import=True)
|
return customfield.to_form_field(for_csv_import=True)
|
||||||
|
|
||||||
|
|
||||||
class NetBoxModelBulkEditForm(CustomFieldsMixin, forms.Form):
|
class NetBoxModelBulkEditForm(CustomFieldsMixin, BulkEditForm):
|
||||||
"""
|
"""
|
||||||
Base form for modifying multiple NetBox objects (of the same type) in bulk via the UI. Adds support for custom
|
Base form for modifying multiple NetBox objects (of the same type) in bulk via the UI. Adds support for custom
|
||||||
fields and adding/removing tags.
|
fields and adding/removing tags.
|
||||||
@@ -108,9 +108,8 @@ class NetBoxModelBulkEditForm(CustomFieldsMixin, forms.Form):
|
|||||||
Attributes:
|
Attributes:
|
||||||
fieldsets: An iterable of two-tuples which define a heading and field set to display per section of
|
fieldsets: An iterable of two-tuples which define a heading and field set to display per section of
|
||||||
the rendered form (optional). If not defined, the all fields will be rendered as a single section.
|
the rendered form (optional). If not defined, the all fields will be rendered as a single section.
|
||||||
nullable_fields: A list of field names indicating which fields support being set to null/empty
|
|
||||||
"""
|
"""
|
||||||
nullable_fields = ()
|
fieldsets = None
|
||||||
|
|
||||||
pk = forms.ModelMultipleChoiceField(
|
pk = forms.ModelMultipleChoiceField(
|
||||||
queryset=None, # Set from self.model on init
|
queryset=None, # Set from self.model on init
|
||||||
|
|||||||
@@ -212,6 +212,5 @@ class AsyncViewJob(JobRunner):
|
|||||||
)
|
)
|
||||||
notification.save()
|
notification.save()
|
||||||
|
|
||||||
# TODO: Waiting on fix for bug #19806
|
if data.errors:
|
||||||
# if errors:
|
raise JobFailed()
|
||||||
# raise JobFailed()
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViol
|
|||||||
from utilities.export import TableExport
|
from utilities.export import TableExport
|
||||||
from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields
|
from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields
|
||||||
from utilities.forms.bulk_import import BulkImportForm
|
from utilities.forms.bulk_import import BulkImportForm
|
||||||
|
from utilities.forms.mixins import BackgroundJobMixin
|
||||||
from utilities.htmx import htmx_partial
|
from utilities.htmx import htmx_partial
|
||||||
from utilities.jobs import AsyncJobData, is_background_request, process_request_as_job
|
from utilities.jobs import AsyncJobData, is_background_request, process_request_as_job
|
||||||
from utilities.permissions import get_permission_for_model
|
from utilities.permissions import get_permission_for_model
|
||||||
@@ -513,12 +514,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
|
|||||||
count=len(form.cleaned_data['data']),
|
count=len(form.cleaned_data['data']),
|
||||||
object_type=model._meta.verbose_name_plural,
|
object_type=model._meta.verbose_name_plural,
|
||||||
)
|
)
|
||||||
if job := process_request_as_job(self.__class__, request, name=job_name):
|
if process_request_as_job(self.__class__, request, name=job_name):
|
||||||
msg = _('Created background job {job.pk}: <a href="{url}">{job.name}</a>').format(
|
|
||||||
url=job.get_absolute_url(),
|
|
||||||
job=job
|
|
||||||
)
|
|
||||||
messages.info(request, mark_safe(msg))
|
|
||||||
return redirect(redirect_url)
|
return redirect(redirect_url)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -712,6 +708,16 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
|||||||
if '_apply' in request.POST:
|
if '_apply' in request.POST:
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
logger.debug("Form validation was successful")
|
logger.debug("Form validation was successful")
|
||||||
|
|
||||||
|
# If indicated, defer this request to a background job & redirect the user
|
||||||
|
if form.cleaned_data['background_job']:
|
||||||
|
job_name = _('Bulk edit {count} {object_type}').format(
|
||||||
|
count=len(form.cleaned_data['pk']),
|
||||||
|
object_type=model._meta.verbose_name_plural,
|
||||||
|
)
|
||||||
|
if process_request_as_job(self.__class__, request, name=job_name):
|
||||||
|
return redirect(self.get_return_url(request))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with transaction.atomic(using=router.db_for_write(model)):
|
with transaction.atomic(using=router.db_for_write(model)):
|
||||||
updated_objects = self._update_objects(form, request)
|
updated_objects = self._update_objects(form, request)
|
||||||
@@ -721,6 +727,16 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
|||||||
if object_count != len(updated_objects):
|
if object_count != len(updated_objects):
|
||||||
raise PermissionsViolation
|
raise PermissionsViolation
|
||||||
|
|
||||||
|
# If this request was executed via a background job, return the raw data for logging
|
||||||
|
if is_background_request(request):
|
||||||
|
return AsyncJobData(
|
||||||
|
log=[
|
||||||
|
_('Updated {object}').format(object=str(obj))
|
||||||
|
for obj in updated_objects
|
||||||
|
],
|
||||||
|
errors=form.errors
|
||||||
|
)
|
||||||
|
|
||||||
if updated_objects:
|
if updated_objects:
|
||||||
msg = f'Updated {len(updated_objects)} {model._meta.verbose_name_plural}'
|
msg = f'Updated {len(updated_objects)} {model._meta.verbose_name_plural}'
|
||||||
logger.info(msg)
|
logger.info(msg)
|
||||||
@@ -876,7 +892,7 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView):
|
|||||||
"""
|
"""
|
||||||
Provide a standard bulk delete form if none has been specified for the view
|
Provide a standard bulk delete form if none has been specified for the view
|
||||||
"""
|
"""
|
||||||
class BulkDeleteForm(ConfirmationForm):
|
class BulkDeleteForm(BackgroundJobMixin, ConfirmationForm):
|
||||||
pk = ModelMultipleChoiceField(queryset=self.queryset, widget=MultipleHiddenInput)
|
pk = ModelMultipleChoiceField(queryset=self.queryset, widget=MultipleHiddenInput)
|
||||||
|
|
||||||
return BulkDeleteForm
|
return BulkDeleteForm
|
||||||
@@ -908,6 +924,15 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView):
|
|||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
logger.debug("Form validation was successful")
|
logger.debug("Form validation was successful")
|
||||||
|
|
||||||
|
# If indicated, defer this request to a background job & redirect the user
|
||||||
|
if form.cleaned_data['background_job']:
|
||||||
|
job_name = _('Bulk delete {count} {object_type}').format(
|
||||||
|
count=len(form.cleaned_data['pk']),
|
||||||
|
object_type=model._meta.verbose_name_plural,
|
||||||
|
)
|
||||||
|
if process_request_as_job(self.__class__, request, name=job_name):
|
||||||
|
return redirect(self.get_return_url(request))
|
||||||
|
|
||||||
# Delete objects
|
# Delete objects
|
||||||
queryset = self.queryset.filter(pk__in=pk_list)
|
queryset = self.queryset.filter(pk__in=pk_list)
|
||||||
deleted_count = queryset.count()
|
deleted_count = queryset.count()
|
||||||
@@ -929,6 +954,16 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView):
|
|||||||
messages.error(request, mark_safe(e.message))
|
messages.error(request, mark_safe(e.message))
|
||||||
return redirect(self.get_return_url(request))
|
return redirect(self.get_return_url(request))
|
||||||
|
|
||||||
|
# If this request was executed via a background job, return the raw data for logging
|
||||||
|
if is_background_request(request):
|
||||||
|
return AsyncJobData(
|
||||||
|
log=[
|
||||||
|
_('Deleted {object}').format(object=str(obj))
|
||||||
|
for obj in queryset
|
||||||
|
],
|
||||||
|
errors=form.errors
|
||||||
|
)
|
||||||
|
|
||||||
msg = _("Deleted {count} {object_type}").format(
|
msg = _("Deleted {count} {object_type}").format(
|
||||||
count=deleted_count,
|
count=deleted_count,
|
||||||
object_type=model._meta.verbose_name_plural
|
object_type=model._meta.verbose_name_plural
|
||||||
|
|||||||
@@ -1,4 +1,67 @@
|
|||||||
{% extends 'generic/object.html' %}
|
{% extends 'generic/object.html' %}
|
||||||
|
{% load helpers %}
|
||||||
|
{% load plugins %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
{% block tabs %}
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col col-12 col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="card-header">{% trans "Image Attachment" %}</h2>
|
||||||
|
<table class="table table-hover attr-table">
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Parent Object" %}</th>
|
||||||
|
<td>{{ object.parent|linkify }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Name" %}</th>
|
||||||
|
<td>{{ object.name|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Description" %}</th>
|
||||||
|
<td>{{ object.description|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% plugin_left_page object %}
|
||||||
|
</div>
|
||||||
|
<div class="col col-12 col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="card-header">{% trans "File" %}</h2>
|
||||||
|
<table class="table table-hover attr-table">
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Filename" %}</th>
|
||||||
|
<td>
|
||||||
|
<a href="{{ object.image.url }}" target="_blank">{{ object.filename }}</a>
|
||||||
|
<i class="mdi mdi-open-in-new"></i>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Dimensions" %}</th>
|
||||||
|
<td>{{ object.image_width }} × {{ object.image_height }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Size" %}</th>
|
||||||
|
<td>
|
||||||
|
<span title="{{ object.size }} {% trans "bytes" %}">{{ object.size|filesizeformat }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% plugin_right_page object %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col col-md-12">
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="card-header">{% trans "Image" %}</h2>
|
||||||
|
<div class="card-body">
|
||||||
|
<a href="{{ object.image.url }}">
|
||||||
|
<img src="{{ object.image.url }}" height="{{ image.height }}" width="{{ image.width }}" alt="{{ object }}" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% plugin_full_width_page object %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
{% extends 'generic/_base.html' %}
|
{% extends 'generic/_base.html' %}
|
||||||
|
{% load form_helpers %}
|
||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
{% load render_table from django_tables2 %}
|
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load render_table from django_tables2 %}
|
||||||
|
|
||||||
{% comment %}
|
{% comment %}
|
||||||
Blocks:
|
Blocks:
|
||||||
@@ -58,13 +59,23 @@ Context:
|
|||||||
<div class="row mt-3">
|
<div class="row mt-3">
|
||||||
<form action="" method="post">
|
<form action="" method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
|
{# Form fields #}
|
||||||
{% for field in form.hidden_fields %}
|
{% for field in form.hidden_fields %}
|
||||||
{{ field }}
|
{{ field }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
|
{# Meta fields #}
|
||||||
|
<div class="bg-primary-subtle border border-primary rounded-1 pt-3 mb-3">
|
||||||
|
{% render_field form.background_job %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Form buttons #}
|
||||||
<div class="text-end">
|
<div class="text-end">
|
||||||
<a href="{{ return_url }}" class="btn btn-outline-secondary">{% trans "Cancel" %}</a>
|
<a href="{{ return_url }}" class="btn btn-outline-secondary">{% trans "Cancel" %}</a>
|
||||||
<button type="submit" name="_confirm" class="btn btn-danger">{% trans "Delete" %} {{ table.rows|length }} {{ model|meta:"verbose_name_plural" }}</button>
|
<button type="submit" name="_confirm" class="btn btn-danger">{% trans "Delete" %} {{ table.rows|length }} {{ model|meta:"verbose_name_plural" }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{% extends 'generic/_base.html' %}
|
{% extends 'generic/_base.html' %}
|
||||||
{% load helpers %}
|
|
||||||
{% load form_helpers %}
|
{% load form_helpers %}
|
||||||
{% load render_table from django_tables2 %}
|
{% load helpers %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load render_table from django_tables2 %}
|
||||||
|
|
||||||
{% comment %}
|
{% comment %}
|
||||||
Blocks:
|
Blocks:
|
||||||
@@ -102,6 +102,11 @@ Context:
|
|||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# Meta fields #}
|
||||||
|
<div class="bg-primary-subtle border border-primary rounded-1 pt-3 mb-3">
|
||||||
|
{% render_field form.background_job %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="btn-float-group-right">
|
<div class="btn-float-group-right">
|
||||||
<a href="{{ return_url }}" class="btn btn-outline-secondary btn-float">{% trans "Cancel" %}</a>
|
<a href="{{ return_url }}" class="btn btn-outline-secondary btn-float">{% trans "Cancel" %}</a>
|
||||||
<button type="submit" name="_apply" class="btn btn-primary">{% trans "Apply" %}</button>
|
<button type="submit" name="_apply" class="btn btn-primary">{% trans "Apply" %}</button>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends 'generic/_base.html' %}
|
{% extends 'generic/_base.html' %}
|
||||||
{% load helpers %}
|
|
||||||
{% load form_helpers %}
|
{% load form_helpers %}
|
||||||
|
{% load helpers %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% comment %}
|
{% comment %}
|
||||||
@@ -47,10 +47,17 @@ Context:
|
|||||||
<form action="" method="post" enctype="multipart/form-data" class="form">
|
<form action="" method="post" enctype="multipart/form-data" class="form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="import_method" value="direct" />
|
<input type="hidden" name="import_method" value="direct" />
|
||||||
|
|
||||||
|
{# Form fields #}
|
||||||
{% render_field form.data %}
|
{% render_field form.data %}
|
||||||
{% render_field form.format %}
|
{% render_field form.format %}
|
||||||
{% render_field form.csv_delimiter %}
|
{% render_field form.csv_delimiter %}
|
||||||
{% render_field form.background_job %}
|
|
||||||
|
{# Meta fields #}
|
||||||
|
<div class="bg-primary-subtle border border-primary rounded-1 pt-3 mb-3">
|
||||||
|
{% render_field form.background_job %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="col col-md-12 text-end">
|
<div class="col col-md-12 text-end">
|
||||||
{% if return_url %}
|
{% if return_url %}
|
||||||
@@ -70,9 +77,12 @@ Context:
|
|||||||
<form action="" method="post" enctype="multipart/form-data" class="form">
|
<form action="" method="post" enctype="multipart/form-data" class="form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="import_method" value="upload" />
|
<input type="hidden" name="import_method" value="upload" />
|
||||||
|
|
||||||
|
{# Form fields #}
|
||||||
{% render_field form.upload_file %}
|
{% render_field form.upload_file %}
|
||||||
{% render_field form.format %}
|
{% render_field form.format %}
|
||||||
{% render_field form.csv_delimiter %}
|
{% render_field form.csv_delimiter %}
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="col col-md-12 text-end">
|
<div class="col col-md-12 text-end">
|
||||||
{% if return_url %}
|
{% if return_url %}
|
||||||
@@ -91,11 +101,18 @@ Context:
|
|||||||
<form action="" method="post" enctype="multipart/form-data" class="form">
|
<form action="" method="post" enctype="multipart/form-data" class="form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="import_method" value="datafile" />
|
<input type="hidden" name="import_method" value="datafile" />
|
||||||
|
|
||||||
|
{# Form fields #}
|
||||||
{% render_field form.data_source %}
|
{% render_field form.data_source %}
|
||||||
{% render_field form.data_file %}
|
{% render_field form.data_file %}
|
||||||
{% render_field form.format %}
|
{% render_field form.format %}
|
||||||
{% render_field form.csv_delimiter %}
|
{% render_field form.csv_delimiter %}
|
||||||
{% render_field form.background_job %}
|
|
||||||
|
{# Meta fields #}
|
||||||
|
<div class="bg-primary-subtle border border-primary rounded-1 pt-3 mb-3">
|
||||||
|
{% render_field form.background_job %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="col col-md-12 text-end">
|
<div class="col col-md-12 text-end">
|
||||||
{% if return_url %}
|
{% if return_url %}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ __all__ = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class UserBulkEditForm(forms.Form):
|
class UserBulkEditForm(BulkEditForm):
|
||||||
pk = forms.ModelMultipleChoiceField(
|
pk = forms.ModelMultipleChoiceField(
|
||||||
queryset=User.objects.all(),
|
queryset=User.objects.all(),
|
||||||
widget=forms.MultipleHiddenInput
|
widget=forms.MultipleHiddenInput
|
||||||
@@ -55,7 +55,7 @@ class UserBulkEditForm(forms.Form):
|
|||||||
nullable_fields = ('first_name', 'last_name')
|
nullable_fields = ('first_name', 'last_name')
|
||||||
|
|
||||||
|
|
||||||
class GroupBulkEditForm(forms.Form):
|
class GroupBulkEditForm(BulkEditForm):
|
||||||
pk = forms.ModelMultipleChoiceField(
|
pk = forms.ModelMultipleChoiceField(
|
||||||
queryset=Group.objects.all(),
|
queryset=Group.objects.all(),
|
||||||
widget=forms.MultipleHiddenInput
|
widget=forms.MultipleHiddenInput
|
||||||
@@ -73,7 +73,7 @@ class GroupBulkEditForm(forms.Form):
|
|||||||
nullable_fields = ('description',)
|
nullable_fields = ('description',)
|
||||||
|
|
||||||
|
|
||||||
class ObjectPermissionBulkEditForm(forms.Form):
|
class ObjectPermissionBulkEditForm(BulkEditForm):
|
||||||
pk = forms.ModelMultipleChoiceField(
|
pk = forms.ModelMultipleChoiceField(
|
||||||
queryset=ObjectPermission.objects.all(),
|
queryset=ObjectPermission.objects.all(),
|
||||||
widget=forms.MultipleHiddenInput
|
widget=forms.MultipleHiddenInput
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ from django.utils.translation import gettext as _
|
|||||||
from core.forms.mixins import SyncedDataMixin
|
from core.forms.mixins import SyncedDataMixin
|
||||||
from netbox.choices import CSVDelimiterChoices, ImportFormatChoices, ImportMethodChoices
|
from netbox.choices import CSVDelimiterChoices, ImportFormatChoices, ImportMethodChoices
|
||||||
from utilities.constants import CSV_DELIMITERS
|
from utilities.constants import CSV_DELIMITERS
|
||||||
|
from utilities.forms.mixins import BackgroundJobMixin
|
||||||
from utilities.forms.utils import parse_csv
|
from utilities.forms.utils import parse_csv
|
||||||
|
|
||||||
|
|
||||||
class BulkImportForm(SyncedDataMixin, forms.Form):
|
class BulkImportForm(BackgroundJobMixin, SyncedDataMixin, forms.Form):
|
||||||
import_method = forms.ChoiceField(
|
import_method = forms.ChoiceField(
|
||||||
choices=ImportMethodChoices,
|
choices=ImportMethodChoices,
|
||||||
required=False
|
required=False
|
||||||
@@ -37,11 +38,6 @@ class BulkImportForm(SyncedDataMixin, forms.Form):
|
|||||||
help_text=_("The character which delimits CSV fields. Applies only to CSV format."),
|
help_text=_("The character which delimits CSV fields. Applies only to CSV format."),
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
background_job = forms.BooleanField(
|
|
||||||
label=_('Background job'),
|
|
||||||
help_text=_("Enqueue a background job to complete the bulk import/update."),
|
|
||||||
required=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
data_field = 'data'
|
data_field = 'data'
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import re
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from utilities.forms.mixins import BackgroundJobMixin
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'BulkEditForm',
|
'BulkEditForm',
|
||||||
'BulkRenameForm',
|
'BulkRenameForm',
|
||||||
@@ -28,9 +30,12 @@ class ConfirmationForm(forms.Form):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class BulkEditForm(forms.Form):
|
class BulkEditForm(BackgroundJobMixin, forms.Form):
|
||||||
"""
|
"""
|
||||||
Provides bulk edit support for objects.
|
Provides bulk edit support for objects.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
nullable_fields: A list of field names indicating which fields support being set to null/empty
|
||||||
"""
|
"""
|
||||||
nullable_fields = ()
|
nullable_fields = ()
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,20 @@ from django.core.validators import MaxValueValidator, MinValueValidator
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
'BackgroundJobMixin',
|
||||||
'CheckLastUpdatedMixin',
|
'CheckLastUpdatedMixin',
|
||||||
'DistanceValidationMixin',
|
'DistanceValidationMixin',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BackgroundJobMixin(forms.Form):
|
||||||
|
background_job = forms.BooleanField(
|
||||||
|
label=_('Background job'),
|
||||||
|
help_text=_("Execute this task via a background job"),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CheckLastUpdatedMixin(forms.Form):
|
class CheckLastUpdatedMixin(forms.Form):
|
||||||
"""
|
"""
|
||||||
Checks whether the object being saved has been updated since the form was initialized. If so, validation fails.
|
Checks whether the object being saved has been updated since the form was initialized. If so, validation fails.
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from netbox.jobs import AsyncViewJob
|
from netbox.jobs import AsyncViewJob
|
||||||
from utilities.request import copy_safe_request
|
from utilities.request import copy_safe_request
|
||||||
|
|
||||||
@@ -38,9 +42,19 @@ def process_request_as_job(view, request, name=None):
|
|||||||
request_copy._background = True
|
request_copy._background = True
|
||||||
|
|
||||||
# Enqueue a job to perform the work in the background
|
# Enqueue a job to perform the work in the background
|
||||||
return AsyncViewJob.enqueue(
|
job = AsyncViewJob.enqueue(
|
||||||
name=name,
|
name=name,
|
||||||
user=request.user,
|
user=request.user,
|
||||||
view_cls=view,
|
view_cls=view,
|
||||||
request=request_copy,
|
request=request_copy,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Record a message on the original request indicating deferral to a background job
|
||||||
|
msg = _('Created background job {id}: <a href="{url}">{name}</a>').format(
|
||||||
|
id=job.pk,
|
||||||
|
url=job.get_absolute_url(),
|
||||||
|
name=job.name
|
||||||
|
)
|
||||||
|
messages.info(request, mark_safe(msg))
|
||||||
|
|
||||||
|
return job
|
||||||
|
|||||||
Reference in New Issue
Block a user