Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2019-05-13 23:07:12 +10:00
commit 787ab0a2e4
29 changed files with 302 additions and 116 deletions

View File

@ -17,3 +17,19 @@ class HelperForm(forms.ModelForm):
self.helper = FormHelper() self.helper = FormHelper()
self.helper.form_tag = False self.helper.form_tag = False
class DeleteForm(forms.Form):
""" Generic deletion form which provides simple user confirmation
"""
confirm_delete = forms.BooleanField(
required=False,
initial=False,
help_text='Confirm item deletion'
)
class Meta:
fields = [
'confirm_delete'
]

View File

@ -12,11 +12,14 @@ from django.template.loader import render_to_string
from django.http import JsonResponse from django.http import JsonResponse
from django.views import View from django.views import View
from django.views.generic import UpdateView, CreateView, DeleteView from django.views.generic import UpdateView, CreateView
from django.views.generic.base import TemplateView from django.views.generic.base import TemplateView
from part.models import Part from part.models import Part
from .forms import DeleteForm
from .helpers import str2bool
from rest_framework import views from rest_framework import views
@ -116,7 +119,7 @@ class AjaxMixin(object):
""" """
return {} return {}
def renderJsonResponse(self, request, form=None, data={}, context={}): def renderJsonResponse(self, request, form=None, data={}, context=None):
""" Render a JSON response based on specific class context. """ Render a JSON response based on specific class context.
Args: Args:
@ -129,6 +132,12 @@ class AjaxMixin(object):
JSON response object JSON response object
""" """
if context is None:
try:
context = self.get_context_data()
except AttributeError:
context = {}
if form: if form:
context['form'] = form context['form'] = form
@ -294,13 +303,28 @@ class AjaxUpdateView(AjaxMixin, UpdateView):
return self.renderJsonResponse(request, form, data) return self.renderJsonResponse(request, form, data)
class AjaxDeleteView(AjaxMixin, DeleteView): class AjaxDeleteView(AjaxMixin, UpdateView):
""" An 'AJAXified DeleteView for removing an object from the DB """ An 'AJAXified DeleteView for removing an object from the DB
- Returns a HTML object (not a form!) in JSON format (for delivery to a modal window) - Returns a HTML object (not a form!) in JSON format (for delivery to a modal window)
- Handles deletion - Handles deletion
""" """
form_class = DeleteForm
ajax_form_title = "Delete Item"
ajax_template_name = "modal_delete_form.html"
context_object_name = 'item'
def get_object(self):
try:
self.object = self.model.objects.get(pk=self.kwargs['pk'])
except:
return None
return self.object
def get_form(self):
return self.form_class(self.get_form_kwargs())
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
""" Respond to GET request """ Respond to GET request
@ -308,19 +332,15 @@ class AjaxDeleteView(AjaxMixin, DeleteView):
- Return rendered form to client - Return rendered form to client
""" """
super(DeleteView, self).get(request, *args, **kwargs) super(UpdateView, self).get(request, *args, **kwargs)
data = { form = self.get_form()
'id': self.get_object().id,
'delete': False,
'title': self.ajax_form_title,
'html_data': render_to_string(
self.ajax_template_name,
self.get_context_data(),
request=request)
}
return JsonResponse(data) context = self.get_context_data()
context[self.context_object_name] = self.get_object()
return self.renderJsonResponse(request, form, context=context)
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
""" Respond to POST request """ Respond to POST request
@ -331,14 +351,24 @@ class AjaxDeleteView(AjaxMixin, DeleteView):
obj = self.get_object() obj = self.get_object()
pk = obj.id pk = obj.id
form = self.get_form()
confirmed = str2bool(request.POST.get('confirm_delete', False))
context = self.get_context_data()
if confirmed:
obj.delete() obj.delete()
else:
form.errors['confirm_delete'] = ['Check box to confirm item deletion']
context[self.context_object_name] = self.get_object()
data = { data = {
'id': pk, 'id': pk,
'delete': True 'form_valid': confirmed
} }
return self.renderJsonResponse(request, data=data) return self.renderJsonResponse(request, form, data=data, context=context)
class IndexView(TemplateView): class IndexView(TemplateView):

View File

@ -1,3 +1,7 @@
{% extends "modal_delete_form.html" %}
{% block pre_form_content %}
Are you sure you want to unallocate these parts? Are you sure you want to unallocate these parts?
<br> <br>
This will remove {{ item.quantity }} parts from build '{{ item.build.title }}'. This will remove {{ item.quantity }} parts from build '{{ item.build.title }}'.
{% endblock %}

View File

@ -1,3 +1,7 @@
{% extends "modal_delete_form.html" %}
{% block pre_form_content %}
Are you sure you want to delete company '{{ company.name }}'? Are you sure you want to delete company '{{ company.name }}'?
<br> <br>
@ -11,3 +15,5 @@ If this supplier is deleted, these supplier part entries will also be deleted.</
{% endfor %} {% endfor %}
</ul> </ul>
{% endif %} {% endif %}
{% endblock %}

View File

@ -54,7 +54,7 @@
}); });
$('#delete-company').click(function() { $('#delete-company').click(function() {
launchDeleteForm( launchModalForm(
"{% url 'company-delete' company.id %}", "{% url 'company-delete' company.id %}",
{ {
redirect: "{% url 'company-index' %}" redirect: "{% url 'company-index' %}"

View File

@ -1 +1,5 @@
{% extends "modal_delete_form.html" %}
{% block pre_form_content %}
Are you sure you want to delete this supplier part? Are you sure you want to delete this supplier part?
{% endblock %}

View File

@ -109,7 +109,7 @@ InvenTree | {{ company.name }} - Parts
}); });
$('#delete-part').click(function() { $('#delete-part').click(function() {
launchDeleteForm( launchModalForm(
"{% url 'supplier-part-delete' part.id %}", "{% url 'supplier-part-delete' part.id %}",
{ {
redirect: "{% url 'company-index' %}" redirect: "{% url 'company-index' %}"

View File

@ -93,10 +93,12 @@ class CompanyCreate(AjaxCreateView):
class CompanyDelete(AjaxDeleteView): class CompanyDelete(AjaxDeleteView):
""" View for deleting a Company object """ """ View for deleting a Company object """
model = Company model = Company
success_url = '/company/' success_url = '/company/'
ajax_template_name = 'company/delete.html' ajax_template_name = 'company/delete.html'
ajax_form_title = 'Delete Company' ajax_form_title = 'Delete Company'
context_object_name = 'company'
def get_data(self): def get_data(self):
return { return {

View File

@ -75,11 +75,20 @@ class EditPartAttachmentForm(HelperForm):
class EditPartForm(HelperForm): class EditPartForm(HelperForm):
""" Form for editing a Part object """ """ Form for editing a Part object """
confirm_creation = forms.BooleanField(required=False, initial=False, help_text='Confirm part creation', widget=forms.HiddenInput()) deep_copy = forms.BooleanField(required=False,
initial=True,
help_text="Perform 'deep copy' which will duplicate all BOM data for this part",
widget=forms.HiddenInput())
confirm_creation = forms.BooleanField(required=False,
initial=False,
help_text='Confirm part creation',
widget=forms.HiddenInput())
class Meta: class Meta:
model = Part model = Part
fields = [ fields = [
'deep_copy',
'confirm_creation', 'confirm_creation',
'category', 'category',
'name', 'name',

View File

@ -16,6 +16,7 @@ from django.core.exceptions import ValidationError
from django.urls import reverse from django.urls import reverse
from django.conf import settings from django.conf import settings
from django.core.files.base import ContentFile
from django.db import models, transaction from django.db import models, transaction
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
@ -533,6 +534,33 @@ class Part(models.Model):
""" Return the number of supplier parts available for this part """ """ Return the number of supplier parts available for this part """
return self.supplier_parts.count() return self.supplier_parts.count()
def deepCopy(self, other, **kwargs):
""" Duplicates non-field data from another part.
Does not alter the normal fields of this part,
but can be used to copy other data linked by ForeignKey refernce.
Keyword Args:
image: If True, copies Part image (default = True)
bom: If True, copies BOM data (default = False)
"""
# Copy the part image
if kwargs.get('image', True):
image_file = ContentFile(other.image.read())
image_file.name = rename_part_image(self, 'test.png')
self.image = image_file
# Copy the BOM data
if kwargs.get('bom', False):
for item in other.bom_items.all():
# Point the item to THIS part
item.part = self
item.pk = None
item.save()
self.save()
def export_bom(self, **kwargs): def export_bom(self, **kwargs):
data = tablib.Dataset(headers=[ data = tablib.Dataset(headers=[

View File

@ -1,3 +1,7 @@
{% extends "modal_delete_form.html" %}
{% block pre_form_content %}
Are you sure you wish to delete this attachment? Are you sure you wish to delete this attachment?
<br> <br>
This will remove the file '{{ attachment.basename }}'. This will remove the file '{{ attachment.basename }}'.
{% endblock %}

View File

@ -62,7 +62,7 @@
$("#attachment-table").on('click', '.attachment-delete-button', function() { $("#attachment-table").on('click', '.attachment-delete-button', function() {
var button = $(this); var button = $(this);
launchDeleteForm(button.attr('url'), { launchModalForm(button.attr('url'), {
success: function() { success: function() {
location.reload(); location.reload();
} }

View File

@ -1,3 +1,7 @@
{% extends "modal_delete_form.html" %}
{% block pre_form_content %}
Are you sure you want to delete this BOM item? Are you sure you want to delete this BOM item?
<br> <br>
Deleting this entry will remove the BOM row from the following part: Deleting this entry will remove the BOM row from the following part:
@ -7,3 +11,5 @@ Deleting this entry will remove the BOM row from the following part:
<b>{{ item.part.full_name }}</b> - <i>{{ item.part.description }}</i> <b>{{ item.part.full_name }}</b> - <i>{{ item.part.description }}</i>
</li> </li>
</ul> </ul>
{% endblock %}

View File

@ -111,7 +111,7 @@
{% endif %} {% endif %}
$('#cat-delete').click(function() { $('#cat-delete').click(function() {
launchDeleteForm("{% url 'category-delete' category.id %}", launchModalForm("{% url 'category-delete' category.id %}",
{ {
redirect: redirect redirect: redirect
}); });

View File

@ -1,3 +1,6 @@
{% extends "modal_delete_form.html" %}
{% block pre_form_content %}
Are you sure you want to delete category '{{ category.name }}'? Are you sure you want to delete category '{{ category.name }}'?
{% if category.children.all|length > 0 %} {% if category.children.all|length > 0 %}
@ -31,3 +34,5 @@ the top level 'Parts' category.
{% endfor %} {% endfor %}
</ul> </ul>
{% endif %} {% endif %}
{% endblock %}

View File

@ -0,0 +1,24 @@
{% extends "modal_form.html" %}
{% block pre_form_content %}
{{ block.super }}
<div class='alert alert-info alert-block'>
<b>Duplicate Part</b><br>
Make a copy of part '{{ part.full_name }}'.
</div>
{% if matches %}
<b>Possible Matching Parts</b>
<p>The new part may be a duplicate of these existing parts:</p>
<ul class='list-group'>
{% for match in matches %}
<li class='list-group-item list-group-item-condensed'>
{{ match.part.full_name }} - <i>{{ match.part.description }}</i> ({{ match.ratio }}%)
</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}

View File

@ -14,8 +14,8 @@
<span class="caret"></span></button> <span class="caret"></span></button>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
{% if part.active %} {% if part.active %}
<li><a href='#' id='duplicate-part' title='Duplicate Part'>Duplicate</a></li>
<li><a href="#" id='edit-part' title='Edit part'>Edit</a></li> <li><a href="#" id='edit-part' title='Edit part'>Edit</a></li>
<li><a href='#' id='duplicate-part' title='Duplicate Part'>Duplicate</a></li>
<hr> <hr>
<li><a href="#" id='deactivate-part' title='Deactivate part'>Deactivate</a></li> <li><a href="#" id='deactivate-part' title='Deactivate part'>Deactivate</a></li>
{% else %} {% else %}
@ -131,12 +131,9 @@
$("#duplicate-part").click(function() { $("#duplicate-part").click(function() {
launchModalForm( launchModalForm(
"{% url 'part-create' %}", "{% url 'part-duplicate' part.id %}",
{ {
follow: true, follow: true,
data: {
copy: {{ part.id }},
},
} }
); );
}); });
@ -196,7 +193,7 @@
$('#delete-part').click(function() { $('#delete-part').click(function() {
launchDeleteForm( launchModalForm(
"{% url 'part-delete' part.id %}", "{% url 'part-delete' part.id %}",
{ {
redirect: {% if part.category %}"{% url 'category-detail' part.category.id %}"{% else %}"{% url 'part-index' %}"{% endif %} redirect: {% if part.category %}"{% url 'category-detail' part.category.id %}"{% else %}"{% url 'part-index' %}"{% endif %}

View File

@ -36,6 +36,7 @@ part_detail_urls = [
url(r'^delete/?', views.PartDelete.as_view(), name='part-delete'), url(r'^delete/?', views.PartDelete.as_view(), name='part-delete'),
url(r'^bom-export/?', views.BomDownload.as_view(), name='bom-export'), url(r'^bom-export/?', views.BomDownload.as_view(), name='bom-export'),
url(r'^validate-bom/', views.BomValidate.as_view(), name='bom-validate'), url(r'^validate-bom/', views.BomValidate.as_view(), name='bom-validate'),
url(r'^duplicate/', views.PartDuplicate.as_view(), name='part-duplicate'),
url(r'^track/?', views.PartDetail.as_view(template_name='part/track.html'), name='part-track'), url(r'^track/?', views.PartDetail.as_view(template_name='part/track.html'), name='part-track'),
url(r'^attachments/?', views.PartDetail.as_view(template_name='part/attachments.html'), name='part-attachments'), url(r'^attachments/?', views.PartDetail.as_view(template_name='part/attachments.html'), name='part-attachments'),

View File

@ -124,13 +124,122 @@ class PartAttachmentDelete(AjaxDeleteView):
} }
class PartDuplicate(AjaxCreateView):
""" View for duplicating an existing Part object.
- Part <pk> is provided in the URL '/part/<pk>/copy/'
- Option for 'deep-copy' which will duplicate all BOM items (default = True)
"""
model = Part
form_class = part_forms.EditPartForm
ajax_form_title = "Duplicate Part"
ajax_template_name = "part/copy_part.html"
def get_data(self):
return {
'success': 'Copied part'
}
def get_part_to_copy(self):
try:
return Part.objects.get(id=self.kwargs['pk'])
except Part.DoesNotExist:
return None
def get_context_data(self):
return {
'part': self.get_part_to_copy()
}
def get_form(self):
form = super(AjaxCreateView, self).get_form()
# Force display of the 'deep_copy' widget
form.fields['deep_copy'].widget = CheckboxInput()
return form
def post(self, request, *args, **kwargs):
""" Capture the POST request for part duplication
- If the deep_copy object is set, copy all the BOM items too!
"""
form = self.get_form()
context = self.get_context_data()
valid = form.is_valid()
name = request.POST.get('name', None)
if name:
matches = match_part_names(name)
if len(matches) > 0:
context['matches'] = matches
# Enforce display of the checkbox
form.fields['confirm_creation'].widget = CheckboxInput()
# Check if the user has checked the 'confirm_creation' input
confirmed = str2bool(request.POST.get('confirm_creation', False))
if not confirmed:
form.errors['confirm_creation'] = ['Possible matches exist - confirm creation of new part']
form.pre_form_warning = 'Possible matches exist - confirm creation of new part'
valid = False
data = {
'form_valid': valid
}
if valid:
# Create the new Part
part = form.save()
data['pk'] = part.pk
deep_copy = str2bool(request.POST.get('deep_copy', False))
original = self.get_part_to_copy()
if original:
part.deepCopy(original, bom=deep_copy)
try:
data['url'] = part.get_absolute_url()
except AttributeError:
pass
if valid:
pass
return self.renderJsonResponse(request, form, data, context=context)
def get_initial(self):
""" Get initial data based on the Part to be copied from.
"""
part = self.get_part_to_copy()
if part:
initials = model_to_dict(part)
else:
initials = super(AjaxCreateView, self).get_initial()
return initials
class PartCreate(AjaxCreateView): class PartCreate(AjaxCreateView):
""" View for creating a new Part object. """ View for creating a new Part object.
Options for providing initial conditions: Options for providing initial conditions:
- Provide a category object as initial data - Provide a category object as initial data
- Copy an existing Part
""" """
model = Part model = Part
form_class = part_forms.EditPartForm form_class = part_forms.EditPartForm
@ -224,21 +333,8 @@ class PartCreate(AjaxCreateView):
""" Get initial data for the new Part object: """ Get initial data for the new Part object:
- If a category is provided, pre-fill the Category field - If a category is provided, pre-fill the Category field
- If 'copy' parameter is provided, copy from referenced Part
""" """
# Is the client attempting to copy an existing part?
part_to_copy = self.request.GET.get('copy', None)
if part_to_copy:
try:
original = Part.objects.get(pk=part_to_copy)
initials = model_to_dict(original)
self.ajax_form_title = "Copy Part '{p}'".format(p=original.name)
except Part.DoesNotExist:
initials = super(PartCreate, self).get_initial()
else:
initials = super(PartCreate, self).get_initial() initials = super(PartCreate, self).get_initial()
if self.get_category_id(): if self.get_category_id():
@ -708,3 +804,4 @@ class SupplierPartDelete(AjaxDeleteView):
success_url = '/supplier/' success_url = '/supplier/'
ajax_template_name = 'company/partdelete.html' ajax_template_name = 'company/partdelete.html'
ajax_form_title = 'Delete Supplier Part' ajax_form_title = 'Delete Supplier Part'
context_object_name = 'supplier_part'

View File

@ -162,6 +162,10 @@
z-index: 99999999; z-index: 99999999;
} }
.js-modal-form .checkbox {
margin-left: 0px;
}
.modal-dialog { .modal-dialog {
width: 45%; width: 45%;
} }

View File

@ -184,7 +184,7 @@ function loadBomTable(table, options) {
table.on('click', '.bom-delete-button', function() { table.on('click', '.bom-delete-button', function() {
var button = $(this); var button = $(this);
launchDeleteForm(button.attr('url'), { launchModalForm(button.attr('url'), {
success: function() { success: function() {
reloadBomTable(table); reloadBomTable(table);
} }

View File

@ -87,7 +87,7 @@ function loadAllocationTable(table, part_id, part, url, required, button) {
table.on('click', '.item-del-button', function() { table.on('click', '.item-del-button', function() {
var button = $(this); var button = $(this);
launchDeleteForm(button.attr('url'), { launchModalForm(button.attr('url'), {
success: function() { success: function() {
table.bootstrapTable('refresh'); table.bootstrapTable('refresh');
} }

View File

@ -345,68 +345,6 @@ function openModal(options) {
} }
function launchDeleteForm(url, options = {}) {
/* Launch a modal form to delete an object
*/
var modal = options.modal || '#modal-delete';
$(modal).on('shown.bs.modal', function() {
$(modal + ' .modal-form-content').scrollTop(0);
});
// Un-bind any attached click listeners
$(modal).off('click', '#modal-form-delete');
// Request delete form data
$.ajax({
url: url,
type: 'get',
dataType: 'json',
beforeSend: function() {
openModal({modal: modal});
},
success: function (response) {
if (response.title) {
modalSetTitle(modal, response.title);
}
if (response.html_data) {
modalSetContent(modal, response.html_data);
}
else {
$(modal).modal('hide');
showAlertDialog('Invalid form response', 'JSON response missing HTML data');
}
},
error: function (xhr, ajaxOptions, thrownError) {
$(modal).modal('hide');
showAlertDialog('Error requesting form data', renderErrorMessage(xhr));
}
});
$(modal).on('click', '#modal-form-delete', function() {
var form = $(modal).find('#delete-form');
$.ajax({
url: url,
data: form.serialize(),
type: 'post',
dataType: 'json',
success: function (response) {
$(modal).modal('hide');
afterForm(response, options);
},
error: function (xhr, ajaxOptions, thrownError) {
$(modal).modal('hide');
showAlertDialog('Error deleting item', renderErrorMessage(xhr));
}
});
});
}
function injectModalForm(modal, form_html) { function injectModalForm(modal, form_html) {
/* Inject form content into the modal. /* Inject form content into the modal.
* Updates the HTML of the form content, and then applies some other updates * Updates the HTML of the form content, and then applies some other updates

View File

@ -194,7 +194,7 @@
{% endif %} {% endif %}
$("#stock-delete").click(function () { $("#stock-delete").click(function () {
launchDeleteForm( launchModalForm(
"{% url 'stock-item-delete' item.id %}", "{% url 'stock-item-delete' item.id %}",
{ {
redirect: "{% url 'part-stock' item.part.id %}" redirect: "{% url 'part-stock' item.part.id %}"

View File

@ -1,5 +1,11 @@
{% extends "modal_delete_form.html" %}
{% block pre_form_content %}
<div class='alert alert-danger alert-block'>
Are you sure you want to delete this stock item? Are you sure you want to delete this stock item?
<br> <br>
This will remove <b>{{ item.quantity }}</b> units of <b>{{ item.part.full_name }}</b> from stock. This will remove <b>{{ item.quantity }}</b> units of <b>{{ item.part.full_name }}</b> from stock.
</div>
{% endblock %}

View File

@ -97,7 +97,7 @@
}); });
$('#location-delete').click(function() { $('#location-delete').click(function() {
launchDeleteForm("{% url 'stock-location-delete' location.id %}", launchModalForm("{% url 'stock-location-delete' location.id %}",
{ {
redirect: "{% url 'stock-index' %}" redirect: "{% url 'stock-index' %}"
}); });

View File

@ -1,3 +1,6 @@
{% extends "modal_delete_form.html" %}
{% block pre_form_content %}
Are you sure you want to delete stock location '{{ location.name }}'? Are you sure you want to delete stock location '{{ location.name }}'?
<br> <br>
@ -34,3 +37,4 @@ If this location is deleted, these items will be moved to the top level 'Stock'
{% endfor %} {% endfor %}
</ul> </ul>
{% endif %} {% endif %}
{% endblock %}

View File

@ -0,0 +1 @@
{% extends "modal_form.html" %}

View File

@ -1,6 +1,6 @@
<div> <div>
{% if form.pre_form_info %} {% if form.pre_form_info %}
<div class='alert alert-info' role='alert' style='display: block;'> <div class='alert alert-info alert-block' role='alert'>
{{ form.pre_form_info }} {{ form.pre_form_info }}
</div> </div>
{% endif %} {% endif %}