mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
commit
5b1f54a842
@ -153,6 +153,11 @@ class InvenTreeMetadata(SimpleMetadata):
|
|||||||
if 'default' not in field_info and not field.default == empty:
|
if 'default' not in field_info and not field.default == empty:
|
||||||
field_info['default'] = field.get_default()
|
field_info['default'] = field.get_default()
|
||||||
|
|
||||||
|
# Force non-nullable fields to read as "required"
|
||||||
|
# (even if there is a default value!)
|
||||||
|
if not field.allow_null and not (hasattr(field, 'allow_blank') and field.allow_blank):
|
||||||
|
field_info['required'] = True
|
||||||
|
|
||||||
# Introspect writable related fields
|
# Introspect writable related fields
|
||||||
if field_info['type'] == 'field' and not field_info['read_only']:
|
if field_info['type'] == 'field' and not field_info['read_only']:
|
||||||
|
|
||||||
@ -166,7 +171,12 @@ class InvenTreeMetadata(SimpleMetadata):
|
|||||||
if model:
|
if model:
|
||||||
# Mark this field as "related", and point to the URL where we can get the data!
|
# Mark this field as "related", and point to the URL where we can get the data!
|
||||||
field_info['type'] = 'related field'
|
field_info['type'] = 'related field'
|
||||||
field_info['api_url'] = model.get_api_url()
|
|
||||||
field_info['model'] = model._meta.model_name
|
field_info['model'] = model._meta.model_name
|
||||||
|
|
||||||
|
# Special case for 'user' model
|
||||||
|
if field_info['model'] == 'user':
|
||||||
|
field_info['api_url'] = '/api/user/'
|
||||||
|
else:
|
||||||
|
field_info['api_url'] = model.get_api_url()
|
||||||
|
|
||||||
return field_info
|
return field_info
|
||||||
|
@ -5,11 +5,13 @@ JSON API for the Build app
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django.conf.urls import url, include
|
||||||
|
|
||||||
from rest_framework import filters
|
from rest_framework import filters
|
||||||
from rest_framework import generics
|
from rest_framework import generics
|
||||||
|
|
||||||
from django.conf.urls import url, include
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
|
from django_filters import rest_framework as rest_filters
|
||||||
|
|
||||||
from InvenTree.api import AttachmentMixin
|
from InvenTree.api import AttachmentMixin
|
||||||
from InvenTree.helpers import str2bool, isNull
|
from InvenTree.helpers import str2bool, isNull
|
||||||
@ -19,6 +21,36 @@ from .models import Build, BuildItem, BuildOrderAttachment
|
|||||||
from .serializers import BuildAttachmentSerializer, BuildSerializer, BuildItemSerializer
|
from .serializers import BuildAttachmentSerializer, BuildSerializer, BuildItemSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class BuildFilter(rest_filters.FilterSet):
|
||||||
|
"""
|
||||||
|
Custom filterset for BuildList API endpoint
|
||||||
|
"""
|
||||||
|
|
||||||
|
status = rest_filters.NumberFilter(label='Status')
|
||||||
|
|
||||||
|
active = rest_filters.BooleanFilter(label='Build is active', method='filter_active')
|
||||||
|
|
||||||
|
def filter_active(self, queryset, name, value):
|
||||||
|
|
||||||
|
if str2bool(value):
|
||||||
|
queryset = queryset.filter(status__in=BuildStatus.ACTIVE_CODES)
|
||||||
|
else:
|
||||||
|
queryset = queryset.exclude(status__in=BuildStatus.ACTIVE_CODES)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
overdue = rest_filters.BooleanFilter(label='Build is overdue', method='filter_overdue')
|
||||||
|
|
||||||
|
def filter_overdue(self, queryset, name, value):
|
||||||
|
|
||||||
|
if str2bool(value):
|
||||||
|
queryset = queryset.filter(Build.OVERDUE_FILTER)
|
||||||
|
else:
|
||||||
|
queryset = queryset.exclude(Build.OVERDUE_FILTER)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
class BuildList(generics.ListCreateAPIView):
|
class BuildList(generics.ListCreateAPIView):
|
||||||
""" API endpoint for accessing a list of Build objects.
|
""" API endpoint for accessing a list of Build objects.
|
||||||
|
|
||||||
@ -28,6 +60,7 @@ class BuildList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
queryset = Build.objects.all()
|
queryset = Build.objects.all()
|
||||||
serializer_class = BuildSerializer
|
serializer_class = BuildSerializer
|
||||||
|
filterset_class = BuildFilter
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
DjangoFilterBackend,
|
DjangoFilterBackend,
|
||||||
@ -97,34 +130,6 @@ class BuildList(generics.ListCreateAPIView):
|
|||||||
except (ValueError, Build.DoesNotExist):
|
except (ValueError, Build.DoesNotExist):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Filter by build status?
|
|
||||||
status = params.get('status', None)
|
|
||||||
|
|
||||||
if status is not None:
|
|
||||||
queryset = queryset.filter(status=status)
|
|
||||||
|
|
||||||
# Filter by "pending" status
|
|
||||||
active = params.get('active', None)
|
|
||||||
|
|
||||||
if active is not None:
|
|
||||||
active = str2bool(active)
|
|
||||||
|
|
||||||
if active:
|
|
||||||
queryset = queryset.filter(status__in=BuildStatus.ACTIVE_CODES)
|
|
||||||
else:
|
|
||||||
queryset = queryset.exclude(status__in=BuildStatus.ACTIVE_CODES)
|
|
||||||
|
|
||||||
# Filter by "overdue" status?
|
|
||||||
overdue = params.get('overdue', None)
|
|
||||||
|
|
||||||
if overdue is not None:
|
|
||||||
overdue = str2bool(overdue)
|
|
||||||
|
|
||||||
if overdue:
|
|
||||||
queryset = queryset.filter(Build.OVERDUE_FILTER)
|
|
||||||
else:
|
|
||||||
queryset = queryset.exclude(Build.OVERDUE_FILTER)
|
|
||||||
|
|
||||||
# Filter by associated part?
|
# Filter by associated part?
|
||||||
part = params.get('part', None)
|
part = params.get('part', None)
|
||||||
|
|
||||||
|
20
InvenTree/build/migrations/0030_alter_build_reference.py
Normal file
20
InvenTree/build/migrations/0030_alter_build_reference.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 3.2.4 on 2021-07-08 14:14
|
||||||
|
|
||||||
|
import InvenTree.validators
|
||||||
|
import build.models
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('build', '0029_auto_20210601_1525'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='build',
|
||||||
|
name='reference',
|
||||||
|
field=models.CharField(default=build.models.get_next_build_number, help_text='Build Order Reference', max_length=64, unique=True, validators=[InvenTree.validators.validate_build_order_reference], verbose_name='Reference'),
|
||||||
|
),
|
||||||
|
]
|
@ -21,6 +21,7 @@ from django.core.validators import MinValueValidator
|
|||||||
from markdownx.models import MarkdownxField
|
from markdownx.models import MarkdownxField
|
||||||
|
|
||||||
from mptt.models import MPTTModel, TreeForeignKey
|
from mptt.models import MPTTModel, TreeForeignKey
|
||||||
|
from mptt.exceptions import InvalidMove
|
||||||
|
|
||||||
from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode
|
from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode
|
||||||
from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode
|
from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode
|
||||||
@ -37,6 +38,35 @@ from part import models as PartModels
|
|||||||
from users import models as UserModels
|
from users import models as UserModels
|
||||||
|
|
||||||
|
|
||||||
|
def get_next_build_number():
|
||||||
|
"""
|
||||||
|
Returns the next available BuildOrder reference number
|
||||||
|
"""
|
||||||
|
|
||||||
|
if Build.objects.count() == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
build = Build.objects.exclude(reference=None).last()
|
||||||
|
|
||||||
|
attempts = set([build.reference])
|
||||||
|
|
||||||
|
reference = build.reference
|
||||||
|
|
||||||
|
while 1:
|
||||||
|
reference = increment(reference)
|
||||||
|
|
||||||
|
if reference in attempts:
|
||||||
|
# Escape infinite recursion
|
||||||
|
return reference
|
||||||
|
|
||||||
|
if Build.objects.filter(reference=reference).exists():
|
||||||
|
attempts.add(reference)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
return reference
|
||||||
|
|
||||||
|
|
||||||
class Build(MPTTModel):
|
class Build(MPTTModel):
|
||||||
""" A Build object organises the creation of new StockItem objects from other existing StockItem objects.
|
""" A Build object organises the creation of new StockItem objects from other existing StockItem objects.
|
||||||
|
|
||||||
@ -60,11 +90,20 @@ class Build(MPTTModel):
|
|||||||
responsible: User (or group) responsible for completing the build
|
responsible: User (or group) responsible for completing the build
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
OVERDUE_FILTER = Q(status__in=BuildStatus.ACTIVE_CODES) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date())
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_api_url():
|
def get_api_url():
|
||||||
return reverse('api-build-list')
|
return reverse('api-build-list')
|
||||||
|
|
||||||
OVERDUE_FILTER = Q(status__in=BuildStatus.ACTIVE_CODES) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date())
|
def save(self, *args, **kwargs):
|
||||||
|
|
||||||
|
try:
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
except InvalidMove:
|
||||||
|
raise ValidationError({
|
||||||
|
'parent': _('Invalid choice for parent build'),
|
||||||
|
})
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Build Order")
|
verbose_name = _("Build Order")
|
||||||
@ -130,6 +169,7 @@ class Build(MPTTModel):
|
|||||||
blank=False,
|
blank=False,
|
||||||
help_text=_('Build Order Reference'),
|
help_text=_('Build Order Reference'),
|
||||||
verbose_name=_('Reference'),
|
verbose_name=_('Reference'),
|
||||||
|
default=get_next_build_number,
|
||||||
validators=[
|
validators=[
|
||||||
validate_build_order_reference
|
validate_build_order_reference
|
||||||
]
|
]
|
||||||
|
@ -62,7 +62,7 @@ class BuildSerializer(InvenTreeModelSerializer):
|
|||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
part_detail = kwargs.pop('part_detail', False)
|
part_detail = kwargs.pop('part_detail', True)
|
||||||
|
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
@ -75,9 +75,12 @@ class BuildSerializer(InvenTreeModelSerializer):
|
|||||||
'pk',
|
'pk',
|
||||||
'url',
|
'url',
|
||||||
'title',
|
'title',
|
||||||
|
'batch',
|
||||||
'creation_date',
|
'creation_date',
|
||||||
'completed',
|
'completed',
|
||||||
'completion_date',
|
'completion_date',
|
||||||
|
'destination',
|
||||||
|
'parent',
|
||||||
'part',
|
'part',
|
||||||
'part_detail',
|
'part_detail',
|
||||||
'overdue',
|
'overdue',
|
||||||
@ -87,6 +90,7 @@ class BuildSerializer(InvenTreeModelSerializer):
|
|||||||
'status',
|
'status',
|
||||||
'status_text',
|
'status_text',
|
||||||
'target_date',
|
'target_date',
|
||||||
|
'take_from',
|
||||||
'notes',
|
'notes',
|
||||||
'link',
|
'link',
|
||||||
'issued_by',
|
'issued_by',
|
||||||
|
@ -196,10 +196,7 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
});
|
});
|
||||||
|
|
||||||
$("#build-edit").click(function () {
|
$("#build-edit").click(function () {
|
||||||
launchModalForm("{% url 'build-edit' build.id %}",
|
editBuildOrder({{ build.pk }});
|
||||||
{
|
|
||||||
reload: true
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#build-cancel").click(function() {
|
$("#build-cancel").click(function() {
|
||||||
|
@ -5,10 +5,11 @@ from django.test import TestCase
|
|||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db.utils import IntegrityError
|
from django.db.utils import IntegrityError
|
||||||
|
|
||||||
from build.models import Build, BuildItem
|
from InvenTree import status_codes as status
|
||||||
|
|
||||||
|
from build.models import Build, BuildItem, get_next_build_number
|
||||||
from stock.models import StockItem
|
from stock.models import StockItem
|
||||||
from part.models import Part, BomItem
|
from part.models import Part, BomItem
|
||||||
from InvenTree import status_codes as status
|
|
||||||
|
|
||||||
|
|
||||||
class BuildTest(TestCase):
|
class BuildTest(TestCase):
|
||||||
@ -80,8 +81,14 @@ class BuildTest(TestCase):
|
|||||||
quantity=2
|
quantity=2
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ref = get_next_build_number()
|
||||||
|
|
||||||
|
if ref is None:
|
||||||
|
ref = "0001"
|
||||||
|
|
||||||
# Create a "Build" object to make 10x objects
|
# Create a "Build" object to make 10x objects
|
||||||
self.build = Build.objects.create(
|
self.build = Build.objects.create(
|
||||||
|
reference=ref,
|
||||||
title="This is a build",
|
title="This is a build",
|
||||||
part=self.assembly,
|
part=self.assembly,
|
||||||
quantity=10
|
quantity=10
|
||||||
|
@ -252,23 +252,6 @@ class TestBuildViews(TestCase):
|
|||||||
|
|
||||||
self.assertIn(build.title, content)
|
self.assertIn(build.title, content)
|
||||||
|
|
||||||
def test_build_create(self):
|
|
||||||
""" Test the build creation view (ajax form) """
|
|
||||||
|
|
||||||
url = reverse('build-create')
|
|
||||||
|
|
||||||
# Create build without specifying part
|
|
||||||
response = self.client.get(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
# Create build with valid part
|
|
||||||
response = self.client.get(url, {'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
# Create build with invalid part
|
|
||||||
response = self.client.get(url, {'part': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
def test_build_allocate(self):
|
def test_build_allocate(self):
|
||||||
""" Test the part allocation view for a Build """
|
""" Test the part allocation view for a Build """
|
||||||
|
|
||||||
|
@ -7,7 +7,6 @@ from django.conf.urls import url, include
|
|||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
build_detail_urls = [
|
build_detail_urls = [
|
||||||
url(r'^edit/', views.BuildUpdate.as_view(), name='build-edit'),
|
|
||||||
url(r'^allocate/', views.BuildAllocate.as_view(), name='build-allocate'),
|
url(r'^allocate/', views.BuildAllocate.as_view(), name='build-allocate'),
|
||||||
url(r'^cancel/', views.BuildCancel.as_view(), name='build-cancel'),
|
url(r'^cancel/', views.BuildCancel.as_view(), name='build-cancel'),
|
||||||
url(r'^delete/', views.BuildDelete.as_view(), name='build-delete'),
|
url(r'^delete/', views.BuildDelete.as_view(), name='build-delete'),
|
||||||
@ -36,8 +35,6 @@ build_urls = [
|
|||||||
url('^new/', views.BuildItemCreate.as_view(), name='build-item-create'),
|
url('^new/', views.BuildItemCreate.as_view(), name='build-item-create'),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
url(r'new/', views.BuildCreate.as_view(), name='build-create'),
|
|
||||||
|
|
||||||
url(r'^(?P<pk>\d+)/', include(build_detail_urls)),
|
url(r'^(?P<pk>\d+)/', include(build_detail_urls)),
|
||||||
|
|
||||||
url(r'.*$', views.BuildIndex.as_view(), name='build-index'),
|
url(r'.*$', views.BuildIndex.as_view(), name='build-index'),
|
||||||
|
@ -667,126 +667,6 @@ class BuildAllocate(InvenTreeRoleMixin, DetailView):
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
class BuildCreate(AjaxCreateView):
|
|
||||||
"""
|
|
||||||
View to create a new Build object
|
|
||||||
"""
|
|
||||||
|
|
||||||
model = Build
|
|
||||||
context_object_name = 'build'
|
|
||||||
form_class = forms.EditBuildForm
|
|
||||||
ajax_form_title = _('New Build Order')
|
|
||||||
ajax_template_name = 'modal_form.html'
|
|
||||||
|
|
||||||
def get_form(self):
|
|
||||||
form = super().get_form()
|
|
||||||
|
|
||||||
if form['part'].value():
|
|
||||||
form.fields['part'].widget = HiddenInput()
|
|
||||||
|
|
||||||
return form
|
|
||||||
|
|
||||||
def get_initial(self):
|
|
||||||
""" Get initial parameters for Build creation.
|
|
||||||
|
|
||||||
If 'part' is specified in the GET query, initialize the Build with the specified Part
|
|
||||||
"""
|
|
||||||
|
|
||||||
initials = super(BuildCreate, self).get_initial().copy()
|
|
||||||
|
|
||||||
initials['parent'] = self.request.GET.get('parent', None)
|
|
||||||
|
|
||||||
# User has provided a SalesOrder ID
|
|
||||||
initials['sales_order'] = self.request.GET.get('sales_order', None)
|
|
||||||
|
|
||||||
initials['quantity'] = self.request.GET.get('quantity', 1)
|
|
||||||
|
|
||||||
part = self.request.GET.get('part', None)
|
|
||||||
|
|
||||||
if part:
|
|
||||||
|
|
||||||
try:
|
|
||||||
part = Part.objects.get(pk=part)
|
|
||||||
# User has provided a Part ID
|
|
||||||
initials['part'] = part
|
|
||||||
initials['destination'] = part.get_default_location()
|
|
||||||
|
|
||||||
to_order = part.quantity_to_order
|
|
||||||
|
|
||||||
if to_order < 1:
|
|
||||||
to_order = 1
|
|
||||||
|
|
||||||
initials['quantity'] = to_order
|
|
||||||
except (ValueError, Part.DoesNotExist):
|
|
||||||
pass
|
|
||||||
|
|
||||||
initials['reference'] = Build.getNextBuildNumber()
|
|
||||||
|
|
||||||
# Pre-fill the issued_by user
|
|
||||||
initials['issued_by'] = self.request.user
|
|
||||||
|
|
||||||
return initials
|
|
||||||
|
|
||||||
def get_data(self):
|
|
||||||
return {
|
|
||||||
'success': _('Created new build'),
|
|
||||||
}
|
|
||||||
|
|
||||||
def validate(self, build, form, **kwargs):
|
|
||||||
"""
|
|
||||||
Perform extra form validation.
|
|
||||||
|
|
||||||
- If part is trackable, check that either batch or serial numbers are calculated
|
|
||||||
|
|
||||||
By this point form.is_valid() has been executed
|
|
||||||
"""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class BuildUpdate(AjaxUpdateView):
|
|
||||||
""" View for editing a Build object """
|
|
||||||
|
|
||||||
model = Build
|
|
||||||
form_class = forms.EditBuildForm
|
|
||||||
context_object_name = 'build'
|
|
||||||
ajax_form_title = _('Edit Build Order Details')
|
|
||||||
ajax_template_name = 'modal_form.html'
|
|
||||||
|
|
||||||
def get_form(self):
|
|
||||||
|
|
||||||
form = super().get_form()
|
|
||||||
|
|
||||||
build = self.get_object()
|
|
||||||
|
|
||||||
# Fields which are included in the form, but hidden
|
|
||||||
hidden = [
|
|
||||||
'parent',
|
|
||||||
'sales_order',
|
|
||||||
]
|
|
||||||
|
|
||||||
if build.is_complete:
|
|
||||||
# Fields which cannot be edited once the build has been completed
|
|
||||||
|
|
||||||
hidden += [
|
|
||||||
'part',
|
|
||||||
'quantity',
|
|
||||||
'batch',
|
|
||||||
'take_from',
|
|
||||||
'destination',
|
|
||||||
]
|
|
||||||
|
|
||||||
for field in hidden:
|
|
||||||
form.fields[field].widget = HiddenInput()
|
|
||||||
|
|
||||||
return form
|
|
||||||
|
|
||||||
def get_data(self):
|
|
||||||
return {
|
|
||||||
'info': _('Edited build'),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class BuildDelete(AjaxDeleteView):
|
class BuildDelete(AjaxDeleteView):
|
||||||
""" View to delete a build """
|
""" View to delete a build """
|
||||||
|
|
||||||
|
@ -3,7 +3,7 @@ from __future__ import unicode_literals
|
|||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from .models import StockItemLabel, StockLocationLabel
|
from .models import StockItemLabel, StockLocationLabel, PartLabel
|
||||||
|
|
||||||
|
|
||||||
class LabelAdmin(admin.ModelAdmin):
|
class LabelAdmin(admin.ModelAdmin):
|
||||||
@ -13,3 +13,4 @@ class LabelAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
admin.site.register(StockItemLabel, LabelAdmin)
|
admin.site.register(StockItemLabel, LabelAdmin)
|
||||||
admin.site.register(StockLocationLabel, LabelAdmin)
|
admin.site.register(StockLocationLabel, LabelAdmin)
|
||||||
|
admin.site.register(PartLabel, LabelAdmin)
|
||||||
|
@ -15,9 +15,10 @@ import InvenTree.helpers
|
|||||||
import common.models
|
import common.models
|
||||||
|
|
||||||
from stock.models import StockItem, StockLocation
|
from stock.models import StockItem, StockLocation
|
||||||
|
from part.models import Part
|
||||||
|
|
||||||
from .models import StockItemLabel, StockLocationLabel
|
from .models import StockItemLabel, StockLocationLabel, PartLabel
|
||||||
from .serializers import StockItemLabelSerializer, StockLocationLabelSerializer
|
from .serializers import StockItemLabelSerializer, StockLocationLabelSerializer, PartLabelSerializer
|
||||||
|
|
||||||
|
|
||||||
class LabelListView(generics.ListAPIView):
|
class LabelListView(generics.ListAPIView):
|
||||||
@ -132,6 +133,7 @@ class StockItemLabelMixin:
|
|||||||
for key in ['item', 'item[]', 'items', 'items[]']:
|
for key in ['item', 'item[]', 'items', 'items[]']:
|
||||||
if key in params:
|
if key in params:
|
||||||
items = params.getlist(key, [])
|
items = params.getlist(key, [])
|
||||||
|
break
|
||||||
|
|
||||||
valid_ids = []
|
valid_ids = []
|
||||||
|
|
||||||
@ -376,6 +378,112 @@ class StockLocationLabelPrint(generics.RetrieveAPIView, StockLocationLabelMixin,
|
|||||||
return self.print(request, locations)
|
return self.print(request, locations)
|
||||||
|
|
||||||
|
|
||||||
|
class PartLabelMixin:
|
||||||
|
"""
|
||||||
|
Mixin for extracting Part objects from query parameters
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_parts(self):
|
||||||
|
"""
|
||||||
|
Return a list of requested Part objects
|
||||||
|
"""
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
params = self.request.query_params
|
||||||
|
|
||||||
|
for key in ['part', 'part[]', 'parts', 'parts[]']:
|
||||||
|
if key in params:
|
||||||
|
parts = params.getlist(key, [])
|
||||||
|
break
|
||||||
|
|
||||||
|
valid_ids = []
|
||||||
|
|
||||||
|
for part in parts:
|
||||||
|
try:
|
||||||
|
valid_ids.append(int(part))
|
||||||
|
except (ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# List of Part objects which match provided values
|
||||||
|
return Part.objects.filter(pk__in=valid_ids)
|
||||||
|
|
||||||
|
|
||||||
|
class PartLabelList(LabelListView, PartLabelMixin):
|
||||||
|
"""
|
||||||
|
API endpoint for viewing list of PartLabel objects
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = PartLabel.objects.all()
|
||||||
|
serializer_class = PartLabelSerializer
|
||||||
|
|
||||||
|
def filter_queryset(self, queryset):
|
||||||
|
|
||||||
|
queryset = super().filter_queryset(queryset)
|
||||||
|
|
||||||
|
parts = self.get_parts()
|
||||||
|
|
||||||
|
if len(parts) > 0:
|
||||||
|
|
||||||
|
valid_label_ids = set()
|
||||||
|
|
||||||
|
for label in queryset.all():
|
||||||
|
|
||||||
|
matches = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
filters = InvenTree.helpers.validateFilterString(label.filters)
|
||||||
|
except ValidationError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for part in parts:
|
||||||
|
|
||||||
|
part_query = Part.objects.filter(pk=part.pk)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not part_query.filter(**filters).exists():
|
||||||
|
matches = False
|
||||||
|
break
|
||||||
|
except FieldError:
|
||||||
|
matches = False
|
||||||
|
break
|
||||||
|
|
||||||
|
if matches:
|
||||||
|
valid_label_ids.add(label.pk)
|
||||||
|
|
||||||
|
# Reduce queryset to only valid matches
|
||||||
|
queryset = queryset.filter(pk__in=[pk for pk in valid_label_ids])
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
class PartLabelDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
|
"""
|
||||||
|
API endpoint for a single PartLabel object
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = PartLabel.objects.all()
|
||||||
|
serializer_class = PartLabelSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class PartLabelPrint(generics.RetrieveAPIView, PartLabelMixin, LabelPrintMixin):
|
||||||
|
"""
|
||||||
|
API endpoint for printing a PartLabel object
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = PartLabel.objects.all()
|
||||||
|
serializer_class = PartLabelSerializer
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Check if valid part(s) have been provided
|
||||||
|
"""
|
||||||
|
|
||||||
|
parts = self.get_parts()
|
||||||
|
|
||||||
|
return self.print(request, parts)
|
||||||
|
|
||||||
|
|
||||||
label_api_urls = [
|
label_api_urls = [
|
||||||
|
|
||||||
# Stock item labels
|
# Stock item labels
|
||||||
@ -401,4 +509,16 @@ label_api_urls = [
|
|||||||
# List view
|
# List view
|
||||||
url(r'^.*$', StockLocationLabelList.as_view(), name='api-stocklocation-label-list'),
|
url(r'^.*$', StockLocationLabelList.as_view(), name='api-stocklocation-label-list'),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
|
# Part labels
|
||||||
|
url(r'^part/', include([
|
||||||
|
# Detail views
|
||||||
|
url(r'^(?P<pk>\d+)/', include([
|
||||||
|
url(r'^print/', PartLabelPrint.as_view(), name='api-part-label-print'),
|
||||||
|
url(r'^.*$', PartLabelDetail.as_view(), name='api-part-label-detail'),
|
||||||
|
])),
|
||||||
|
|
||||||
|
# List view
|
||||||
|
url(r'^.*$', PartLabelList.as_view(), name='api-part-label-list'),
|
||||||
|
])),
|
||||||
]
|
]
|
||||||
|
@ -37,6 +37,7 @@ class LabelConfig(AppConfig):
|
|||||||
if canAppAccessDatabase():
|
if canAppAccessDatabase():
|
||||||
self.create_stock_item_labels()
|
self.create_stock_item_labels()
|
||||||
self.create_stock_location_labels()
|
self.create_stock_location_labels()
|
||||||
|
self.create_part_labels()
|
||||||
|
|
||||||
def create_stock_item_labels(self):
|
def create_stock_item_labels(self):
|
||||||
"""
|
"""
|
||||||
@ -65,7 +66,7 @@ class LabelConfig(AppConfig):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not os.path.exists(dst_dir):
|
if not os.path.exists(dst_dir):
|
||||||
logger.info(f"Creating missing directory: '{dst_dir}'")
|
logger.info(f"Creating required directory: '{dst_dir}'")
|
||||||
os.makedirs(dst_dir, exist_ok=True)
|
os.makedirs(dst_dir, exist_ok=True)
|
||||||
|
|
||||||
labels = [
|
labels = [
|
||||||
@ -109,7 +110,6 @@ class LabelConfig(AppConfig):
|
|||||||
logger.info(f"Copying label template '{dst_file}'")
|
logger.info(f"Copying label template '{dst_file}'")
|
||||||
shutil.copyfile(src_file, dst_file)
|
shutil.copyfile(src_file, dst_file)
|
||||||
|
|
||||||
try:
|
|
||||||
# Check if a label matching the template already exists
|
# Check if a label matching the template already exists
|
||||||
if StockItemLabel.objects.filter(label=filename).exists():
|
if StockItemLabel.objects.filter(label=filename).exists():
|
||||||
continue
|
continue
|
||||||
@ -125,8 +125,6 @@ class LabelConfig(AppConfig):
|
|||||||
width=label['width'],
|
width=label['width'],
|
||||||
height=label['height'],
|
height=label['height'],
|
||||||
)
|
)
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def create_stock_location_labels(self):
|
def create_stock_location_labels(self):
|
||||||
"""
|
"""
|
||||||
@ -155,7 +153,7 @@ class LabelConfig(AppConfig):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not os.path.exists(dst_dir):
|
if not os.path.exists(dst_dir):
|
||||||
logger.info(f"Creating missing directory: '{dst_dir}'")
|
logger.info(f"Creating required directory: '{dst_dir}'")
|
||||||
os.makedirs(dst_dir, exist_ok=True)
|
os.makedirs(dst_dir, exist_ok=True)
|
||||||
|
|
||||||
labels = [
|
labels = [
|
||||||
@ -206,7 +204,6 @@ class LabelConfig(AppConfig):
|
|||||||
logger.info(f"Copying label template '{dst_file}'")
|
logger.info(f"Copying label template '{dst_file}'")
|
||||||
shutil.copyfile(src_file, dst_file)
|
shutil.copyfile(src_file, dst_file)
|
||||||
|
|
||||||
try:
|
|
||||||
# Check if a label matching the template already exists
|
# Check if a label matching the template already exists
|
||||||
if StockLocationLabel.objects.filter(label=filename).exists():
|
if StockLocationLabel.objects.filter(label=filename).exists():
|
||||||
continue
|
continue
|
||||||
@ -222,5 +219,88 @@ class LabelConfig(AppConfig):
|
|||||||
width=label['width'],
|
width=label['width'],
|
||||||
height=label['height'],
|
height=label['height'],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def create_part_labels(self):
|
||||||
|
"""
|
||||||
|
Create database entries for the default PartLabel templates,
|
||||||
|
if they do not already exist.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .models import PartLabel
|
||||||
except:
|
except:
|
||||||
pass
|
# Database might not yet be ready
|
||||||
|
return
|
||||||
|
|
||||||
|
src_dir = os.path.join(
|
||||||
|
os.path.dirname(os.path.realpath(__file__)),
|
||||||
|
'templates',
|
||||||
|
'label',
|
||||||
|
'part',
|
||||||
|
)
|
||||||
|
|
||||||
|
dst_dir = os.path.join(
|
||||||
|
settings.MEDIA_ROOT,
|
||||||
|
'label',
|
||||||
|
'inventree',
|
||||||
|
'part',
|
||||||
|
)
|
||||||
|
|
||||||
|
if not os.path.exists(dst_dir):
|
||||||
|
logger.info(f"Creating required directory: '{dst_dir}'")
|
||||||
|
os.makedirs(dst_dir, exist_ok=True)
|
||||||
|
|
||||||
|
labels = [
|
||||||
|
{
|
||||||
|
'file': 'part_label.html',
|
||||||
|
'name': 'Part Label',
|
||||||
|
'description': 'Simple part label',
|
||||||
|
'width': 70,
|
||||||
|
'height': 24,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for label in labels:
|
||||||
|
|
||||||
|
filename = os.path.join(
|
||||||
|
'label',
|
||||||
|
'inventree',
|
||||||
|
'part',
|
||||||
|
label['file']
|
||||||
|
)
|
||||||
|
|
||||||
|
src_file = os.path.join(src_dir, label['file'])
|
||||||
|
dst_file = os.path.join(settings.MEDIA_ROOT, filename)
|
||||||
|
|
||||||
|
to_copy = False
|
||||||
|
|
||||||
|
if os.path.exists(dst_file):
|
||||||
|
# File already exists - let's see if it is the "same"
|
||||||
|
|
||||||
|
if not hashFile(dst_file) == hashFile(src_file):
|
||||||
|
logger.info(f"Hash differs for '{filename}'")
|
||||||
|
to_copy = True
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.info(f"Label template '{filename}' is not present")
|
||||||
|
to_copy = True
|
||||||
|
|
||||||
|
if to_copy:
|
||||||
|
logger.info(f"Copying label template '{dst_file}'")
|
||||||
|
shutil.copyfile(src_file, dst_file)
|
||||||
|
|
||||||
|
# Check if a label matching the template already exists
|
||||||
|
if PartLabel.objects.filter(label=filename).exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"Creating entry for PartLabel '{label['name']}'")
|
||||||
|
|
||||||
|
PartLabel.objects.create(
|
||||||
|
name=label['name'],
|
||||||
|
description=label['description'],
|
||||||
|
label=filename,
|
||||||
|
filters='',
|
||||||
|
enabled=True,
|
||||||
|
width=label['width'],
|
||||||
|
height=label['height'],
|
||||||
|
)
|
||||||
|
37
InvenTree/label/migrations/0008_auto_20210708_2106.py
Normal file
37
InvenTree/label/migrations/0008_auto_20210708_2106.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# Generated by Django 3.2.4 on 2021-07-08 11:06
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
import label.models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('label', '0007_auto_20210513_1327'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PartLabel',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(help_text='Label name', max_length=100, verbose_name='Name')),
|
||||||
|
('description', models.CharField(blank=True, help_text='Label description', max_length=250, null=True, verbose_name='Description')),
|
||||||
|
('label', models.FileField(help_text='Label template file', unique=True, upload_to=label.models.rename_label, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html'])], verbose_name='Label')),
|
||||||
|
('enabled', models.BooleanField(default=True, help_text='Label template is enabled', verbose_name='Enabled')),
|
||||||
|
('width', models.FloatField(default=50, help_text='Label width, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Width [mm]')),
|
||||||
|
('height', models.FloatField(default=20, help_text='Label height, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Height [mm]')),
|
||||||
|
('filename_pattern', models.CharField(default='label.pdf', help_text='Pattern for generating label filenames', max_length=100, verbose_name='Filename Pattern')),
|
||||||
|
('filters', models.CharField(blank=True, help_text='Part query filters (comma-separated value of key=value pairs)', max_length=250, validators=[label.models.validate_part_filters], verbose_name='Filters')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='stockitemlabel',
|
||||||
|
name='filters',
|
||||||
|
field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs),', max_length=250, validators=[label.models.validate_stock_item_filters], verbose_name='Filters'),
|
||||||
|
),
|
||||||
|
]
|
@ -25,6 +25,8 @@ from InvenTree.helpers import validateFilterString, normalize
|
|||||||
|
|
||||||
import common.models
|
import common.models
|
||||||
import stock.models
|
import stock.models
|
||||||
|
import part.models
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from django_weasyprint import WeasyTemplateResponseMixin
|
from django_weasyprint import WeasyTemplateResponseMixin
|
||||||
@ -59,6 +61,13 @@ def validate_stock_location_filters(filters):
|
|||||||
return filters
|
return filters
|
||||||
|
|
||||||
|
|
||||||
|
def validate_part_filters(filters):
|
||||||
|
|
||||||
|
filters = validateFilterString(filters, model=part.models.Part)
|
||||||
|
|
||||||
|
return filters
|
||||||
|
|
||||||
|
|
||||||
class WeasyprintLabelMixin(WeasyTemplateResponseMixin):
|
class WeasyprintLabelMixin(WeasyTemplateResponseMixin):
|
||||||
"""
|
"""
|
||||||
Class for rendering a label to a PDF
|
Class for rendering a label to a PDF
|
||||||
@ -246,10 +255,11 @@ class StockItemLabel(LabelTemplate):
|
|||||||
|
|
||||||
filters = models.CharField(
|
filters = models.CharField(
|
||||||
blank=True, max_length=250,
|
blank=True, max_length=250,
|
||||||
help_text=_('Query filters (comma-separated list of key=value pairs'),
|
help_text=_('Query filters (comma-separated list of key=value pairs),'),
|
||||||
verbose_name=_('Filters'),
|
verbose_name=_('Filters'),
|
||||||
validators=[
|
validators=[
|
||||||
validate_stock_item_filters]
|
validate_stock_item_filters
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
def matches_stock_item(self, item):
|
def matches_stock_item(self, item):
|
||||||
@ -335,3 +345,57 @@ class StockLocationLabel(LabelTemplate):
|
|||||||
'location': location,
|
'location': location,
|
||||||
'qr_data': location.format_barcode(brief=True),
|
'qr_data': location.format_barcode(brief=True),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PartLabel(LabelTemplate):
|
||||||
|
"""
|
||||||
|
Template for printing Part labels
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_api_url():
|
||||||
|
return reverse('api-part-label-list')
|
||||||
|
|
||||||
|
SUBDIR = 'part'
|
||||||
|
|
||||||
|
filters = models.CharField(
|
||||||
|
blank=True, max_length=250,
|
||||||
|
help_text=_('Part query filters (comma-separated value of key=value pairs)'),
|
||||||
|
verbose_name=_('Filters'),
|
||||||
|
validators=[
|
||||||
|
validate_part_filters
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def matches_part(self, part):
|
||||||
|
"""
|
||||||
|
Test if this label template matches a given Part object
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
filters = validateFilterString(self.filters)
|
||||||
|
parts = part.models.Part.objects.filter(**filters)
|
||||||
|
except (ValidationError, FieldError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
parts = parts.filter(pk=part.pk)
|
||||||
|
|
||||||
|
return parts.exists()
|
||||||
|
|
||||||
|
def get_context_data(self, request):
|
||||||
|
"""
|
||||||
|
Generate context data for each provided Part object
|
||||||
|
"""
|
||||||
|
|
||||||
|
part = self.object_to_print
|
||||||
|
|
||||||
|
return {
|
||||||
|
'part': part,
|
||||||
|
'category': part.category,
|
||||||
|
'name': part.name,
|
||||||
|
'description': part.description,
|
||||||
|
'IPN': part.IPN,
|
||||||
|
'revision': part.revision,
|
||||||
|
'qr_data': part.format_barcode(brief=True),
|
||||||
|
'qr_url': part.format_barcode(url=True, request=request),
|
||||||
|
}
|
||||||
|
@ -4,7 +4,7 @@ from __future__ import unicode_literals
|
|||||||
from InvenTree.serializers import InvenTreeModelSerializer
|
from InvenTree.serializers import InvenTreeModelSerializer
|
||||||
from InvenTree.serializers import InvenTreeAttachmentSerializerField
|
from InvenTree.serializers import InvenTreeAttachmentSerializerField
|
||||||
|
|
||||||
from .models import StockItemLabel, StockLocationLabel
|
from .models import StockItemLabel, StockLocationLabel, PartLabel
|
||||||
|
|
||||||
|
|
||||||
class StockItemLabelSerializer(InvenTreeModelSerializer):
|
class StockItemLabelSerializer(InvenTreeModelSerializer):
|
||||||
@ -43,3 +43,22 @@ class StockLocationLabelSerializer(InvenTreeModelSerializer):
|
|||||||
'filters',
|
'filters',
|
||||||
'enabled',
|
'enabled',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class PartLabelSerializer(InvenTreeModelSerializer):
|
||||||
|
"""
|
||||||
|
Serializes a PartLabel object
|
||||||
|
"""
|
||||||
|
|
||||||
|
label = InvenTreeAttachmentSerializerField(required=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = PartLabel
|
||||||
|
fields = [
|
||||||
|
'pk',
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'label',
|
||||||
|
'filters',
|
||||||
|
'enabled',
|
||||||
|
]
|
||||||
|
33
InvenTree/label/templates/label/part/part_label.html
Normal file
33
InvenTree/label/templates/label/part/part_label.html
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
{% extends "label/label_base.html" %}
|
||||||
|
|
||||||
|
{% load barcode %}
|
||||||
|
|
||||||
|
{% block style %}
|
||||||
|
|
||||||
|
.qr {
|
||||||
|
position: fixed;
|
||||||
|
left: 0mm;
|
||||||
|
top: 0mm;
|
||||||
|
height: {{ height }}mm;
|
||||||
|
width: {{ height }}mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part {
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
display: inline;
|
||||||
|
position: absolute;
|
||||||
|
left: {{ height }}mm;
|
||||||
|
top: 2mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<img class='qr' src='{% qrcode qr_data %}'>
|
||||||
|
|
||||||
|
<div class='part'>
|
||||||
|
{{ part.full_name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
@ -43,8 +43,10 @@ def get_next_po_number():
|
|||||||
|
|
||||||
attempts = set([order.reference])
|
attempts = set([order.reference])
|
||||||
|
|
||||||
|
reference = order.reference
|
||||||
|
|
||||||
while 1:
|
while 1:
|
||||||
reference = increment(order.reference)
|
reference = increment(reference)
|
||||||
|
|
||||||
if reference in attempts:
|
if reference in attempts:
|
||||||
# Escape infinite recursion
|
# Escape infinite recursion
|
||||||
@ -70,8 +72,10 @@ def get_next_so_number():
|
|||||||
|
|
||||||
attempts = set([order.reference])
|
attempts = set([order.reference])
|
||||||
|
|
||||||
|
reference = order.reference
|
||||||
|
|
||||||
while 1:
|
while 1:
|
||||||
reference = increment(order.reference)
|
reference = increment(reference)
|
||||||
|
|
||||||
if reference in attempts:
|
if reference in attempts:
|
||||||
# Escape infinite recursion
|
# Escape infinite recursion
|
||||||
|
@ -425,18 +425,18 @@ class PartFilter(rest_filters.FilterSet):
|
|||||||
else:
|
else:
|
||||||
queryset = queryset.filter(IPN='')
|
queryset = queryset.filter(IPN='')
|
||||||
|
|
||||||
|
# Regex filter for name
|
||||||
|
name_regex = rest_filters.CharFilter(label='Filter by name (regex)', field_name='name', lookup_expr='iregex')
|
||||||
|
|
||||||
# Exact match for IPN
|
# Exact match for IPN
|
||||||
ipn = rest_filters.CharFilter(
|
IPN = rest_filters.CharFilter(
|
||||||
label='Filter by exact IPN (internal part number)',
|
label='Filter by exact IPN (internal part number)',
|
||||||
field_name='IPN',
|
field_name='IPN',
|
||||||
lookup_expr="iexact"
|
lookup_expr="iexact"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Regex match for IPN
|
# Regex match for IPN
|
||||||
ipn_regex = rest_filters.CharFilter(
|
IPN_regex = rest_filters.CharFilter(label='Filter by regex on IPN (internal part number)', field_name='IPN', lookup_expr='iregex')
|
||||||
label='Filter by regex on IPN (internal part number) field',
|
|
||||||
field_name='IPN', lookup_expr='iregex'
|
|
||||||
)
|
|
||||||
|
|
||||||
# low_stock filter
|
# low_stock filter
|
||||||
low_stock = rest_filters.BooleanFilter(label='Low stock', method='filter_low_stock')
|
low_stock = rest_filters.BooleanFilter(label='Low stock', method='filter_low_stock')
|
||||||
@ -1115,10 +1115,10 @@ part_api_urls = [
|
|||||||
|
|
||||||
# Base URL for PartParameter API endpoints
|
# Base URL for PartParameter API endpoints
|
||||||
url(r'^parameter/', include([
|
url(r'^parameter/', include([
|
||||||
url(r'^template/$', PartParameterTemplateList.as_view(), name='api-part-param-template-list'),
|
url(r'^template/$', PartParameterTemplateList.as_view(), name='api-part-parameter-template-list'),
|
||||||
|
|
||||||
url(r'^(?P<pk>\d+)/', PartParameterDetail.as_view(), name='api-part-param-detail'),
|
url(r'^(?P<pk>\d+)/', PartParameterDetail.as_view(), name='api-part-parameter-detail'),
|
||||||
url(r'^.*$', PartParameterList.as_view(), name='api-part-param-list'),
|
url(r'^.*$', PartParameterList.as_view(), name='api-part-parameter-list'),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
url(r'^thumbs/', include([
|
url(r'^thumbs/', include([
|
||||||
|
@ -2164,7 +2164,7 @@ class PartParameterTemplate(models.Model):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_api_url():
|
def get_api_url():
|
||||||
return reverse('api-part-param-template-list')
|
return reverse('api-part-parameter-template-list')
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
s = str(self.name)
|
s = str(self.name)
|
||||||
@ -2205,7 +2205,7 @@ class PartParameter(models.Model):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_api_url():
|
def get_api_url():
|
||||||
return reverse('api-part-param-list')
|
return reverse('api-part-parameter-list')
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
# String representation of a PartParameter (used in the admin interface)
|
# String representation of a PartParameter (used in the admin interface)
|
||||||
|
@ -508,19 +508,6 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class PartParameterSerializer(InvenTreeModelSerializer):
|
|
||||||
""" JSON serializers for the PartParameter model """
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = PartParameter
|
|
||||||
fields = [
|
|
||||||
'pk',
|
|
||||||
'part',
|
|
||||||
'template',
|
|
||||||
'data'
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class PartParameterTemplateSerializer(InvenTreeModelSerializer):
|
class PartParameterTemplateSerializer(InvenTreeModelSerializer):
|
||||||
""" JSON serializer for the PartParameterTemplate model """
|
""" JSON serializer for the PartParameterTemplate model """
|
||||||
|
|
||||||
@ -533,6 +520,22 @@ class PartParameterTemplateSerializer(InvenTreeModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class PartParameterSerializer(InvenTreeModelSerializer):
|
||||||
|
""" JSON serializers for the PartParameter model """
|
||||||
|
|
||||||
|
template_detail = PartParameterTemplateSerializer(source='template', many=False, read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = PartParameter
|
||||||
|
fields = [
|
||||||
|
'pk',
|
||||||
|
'part',
|
||||||
|
'template',
|
||||||
|
'template_detail',
|
||||||
|
'data'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class CategoryParameterTemplateSerializer(InvenTreeModelSerializer):
|
class CategoryParameterTemplateSerializer(InvenTreeModelSerializer):
|
||||||
""" Serializer for PartCategoryParameterTemplate """
|
""" Serializer for PartCategoryParameterTemplate """
|
||||||
|
|
||||||
|
@ -34,9 +34,7 @@
|
|||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
$("#start-build").click(function() {
|
$("#start-build").click(function() {
|
||||||
newBuildOrder({
|
newBuildOrder({
|
||||||
data: {
|
part: {{ part.pk }},
|
||||||
part: {{ part.id }},
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,5 +0,0 @@
|
|||||||
{% extends "modal_delete_form.html" %}
|
|
||||||
|
|
||||||
{% block pre_form_content %}
|
|
||||||
Are you sure you want to remove this parameter?
|
|
||||||
{% endblock %}
|
|
@ -21,54 +21,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table id='param-table' class='table table-condensed table-striped' data-toolbar='#button-toolbar'>
|
<table id='parameter-table' class='table table-condensed table-striped' data-toolbar="#button-toolbar"></table>
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th data-field='name' data-serachable='true'>{% trans "Name" %}</th>
|
|
||||||
<th data-field='value' data-searchable='true'>{% trans "Value" %}</th>
|
|
||||||
<th data-field='units' data-searchable='true'>{% trans "Units" %}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for param in part.get_parameters %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ param.template.name }}</td>
|
|
||||||
<td>{{ param.data }}</td>
|
|
||||||
<td>
|
|
||||||
{{ param.template.units }}
|
|
||||||
<div class='btn-group' style='float: right;'>
|
|
||||||
{% if roles.part.change %}
|
|
||||||
<button title='{% trans "Edit" %}' class='btn btn-default btn-glyph param-edit' url="{% url 'part-param-edit' param.id %}" type='button'><span class='fas fa-edit'/></button>
|
|
||||||
{% endif %}
|
|
||||||
{% if roles.part.change %}
|
|
||||||
<button title='{% trans "Delete" %}' class='btn btn-default btn-glyph param-delete' url="{% url 'part-param-delete' param.id %}" type='button'><span class='fas fa-trash-alt icon-red'/></button>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block js_ready %}
|
{% block js_ready %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
|
||||||
|
loadPartParameterTable(
|
||||||
|
'#parameter-table',
|
||||||
|
'{% url "api-part-parameter-list" %}',
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
part: {{ part.pk }},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
$('#param-table').inventreeTable({
|
$('#param-table').inventreeTable({
|
||||||
});
|
});
|
||||||
|
|
||||||
{% if roles.part.add %}
|
{% if roles.part.add %}
|
||||||
$('#param-create').click(function() {
|
$('#param-create').click(function() {
|
||||||
launchModalForm("{% url 'part-param-create' %}?part={{ part.id }}", {
|
|
||||||
reload: true,
|
constructForm('{% url "api-part-parameter-list" %}', {
|
||||||
secondary: [{
|
method: 'POST',
|
||||||
field: 'template',
|
fields: {
|
||||||
label: '{% trans "New Template" %}',
|
part: {
|
||||||
title: '{% trans "Create New Parameter Template" %}',
|
value: {{ part.pk }},
|
||||||
url: "{% url 'part-param-template-create' %}"
|
hidden: true,
|
||||||
}],
|
},
|
||||||
|
template: {},
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
title: '{% trans "Add Parameter" %}',
|
||||||
|
onSuccess: function() {
|
||||||
|
$('#parameter-table').bootstrapTable('refresh');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -268,6 +268,10 @@
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('#print-label').click(function() {
|
||||||
|
printPartLabels([{{ part.pk }}]);
|
||||||
|
});
|
||||||
|
|
||||||
$("#part-count").click(function() {
|
$("#part-count").click(function() {
|
||||||
launchModalForm("/stock/adjust/", {
|
launchModalForm("/stock/adjust/", {
|
||||||
data: {
|
data: {
|
||||||
|
@ -805,7 +805,7 @@ class PartParameterTest(InvenTreeAPITestCase):
|
|||||||
Test for listing part parameters
|
Test for listing part parameters
|
||||||
"""
|
"""
|
||||||
|
|
||||||
url = reverse('api-part-param-list')
|
url = reverse('api-part-parameter-list')
|
||||||
|
|
||||||
response = self.client.get(url, format='json')
|
response = self.client.get(url, format='json')
|
||||||
|
|
||||||
@ -838,7 +838,7 @@ class PartParameterTest(InvenTreeAPITestCase):
|
|||||||
Test that we can create a param via the API
|
Test that we can create a param via the API
|
||||||
"""
|
"""
|
||||||
|
|
||||||
url = reverse('api-part-param-list')
|
url = reverse('api-part-parameter-list')
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
url,
|
url,
|
||||||
@ -860,7 +860,7 @@ class PartParameterTest(InvenTreeAPITestCase):
|
|||||||
Tests for the PartParameter detail endpoint
|
Tests for the PartParameter detail endpoint
|
||||||
"""
|
"""
|
||||||
|
|
||||||
url = reverse('api-part-param-detail', kwargs={'pk': 5})
|
url = reverse('api-part-parameter-detail', kwargs={'pk': 5})
|
||||||
|
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
|
|
||||||
|
@ -33,10 +33,6 @@ part_parameter_urls = [
|
|||||||
url(r'^template/new/', views.PartParameterTemplateCreate.as_view(), name='part-param-template-create'),
|
url(r'^template/new/', views.PartParameterTemplateCreate.as_view(), name='part-param-template-create'),
|
||||||
url(r'^template/(?P<pk>\d+)/edit/', views.PartParameterTemplateEdit.as_view(), name='part-param-template-edit'),
|
url(r'^template/(?P<pk>\d+)/edit/', views.PartParameterTemplateEdit.as_view(), name='part-param-template-edit'),
|
||||||
url(r'^template/(?P<pk>\d+)/delete/', views.PartParameterTemplateDelete.as_view(), name='part-param-template-edit'),
|
url(r'^template/(?P<pk>\d+)/delete/', views.PartParameterTemplateDelete.as_view(), name='part-param-template-edit'),
|
||||||
|
|
||||||
url(r'^new/', views.PartParameterCreate.as_view(), name='part-param-create'),
|
|
||||||
url(r'^(?P<pk>\d+)/edit/', views.PartParameterEdit.as_view(), name='part-param-edit'),
|
|
||||||
url(r'^(?P<pk>\d+)/delete/', views.PartParameterDelete.as_view(), name='part-param-delete'),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
part_detail_urls = [
|
part_detail_urls = [
|
||||||
|
@ -32,7 +32,7 @@ from rapidfuzz import fuzz
|
|||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
from .models import PartCategory, Part, PartRelated
|
from .models import PartCategory, Part, PartRelated
|
||||||
from .models import PartParameterTemplate, PartParameter
|
from .models import PartParameterTemplate
|
||||||
from .models import PartCategoryParameterTemplate
|
from .models import PartCategoryParameterTemplate
|
||||||
from .models import BomItem
|
from .models import BomItem
|
||||||
from .models import match_part_names
|
from .models import match_part_names
|
||||||
@ -2257,78 +2257,6 @@ class PartParameterTemplateDelete(AjaxDeleteView):
|
|||||||
ajax_form_title = _("Delete Part Parameter Template")
|
ajax_form_title = _("Delete Part Parameter Template")
|
||||||
|
|
||||||
|
|
||||||
class PartParameterCreate(AjaxCreateView):
|
|
||||||
""" View for creating a new PartParameter """
|
|
||||||
|
|
||||||
model = PartParameter
|
|
||||||
form_class = part_forms.EditPartParameterForm
|
|
||||||
ajax_form_title = _('Create Part Parameter')
|
|
||||||
|
|
||||||
def get_initial(self):
|
|
||||||
|
|
||||||
initials = {}
|
|
||||||
|
|
||||||
part_id = self.request.GET.get('part', None)
|
|
||||||
|
|
||||||
if part_id:
|
|
||||||
try:
|
|
||||||
initials['part'] = Part.objects.get(pk=part_id)
|
|
||||||
except (Part.DoesNotExist, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
return initials
|
|
||||||
|
|
||||||
def get_form(self):
|
|
||||||
""" Return the form object.
|
|
||||||
|
|
||||||
- Hide the 'Part' field (specified in URL)
|
|
||||||
- Limit the 'Template' options (to avoid duplicates)
|
|
||||||
"""
|
|
||||||
|
|
||||||
form = super().get_form()
|
|
||||||
|
|
||||||
part_id = self.request.GET.get('part', None)
|
|
||||||
|
|
||||||
if part_id:
|
|
||||||
try:
|
|
||||||
part = Part.objects.get(pk=part_id)
|
|
||||||
|
|
||||||
form.fields['part'].widget = HiddenInput()
|
|
||||||
|
|
||||||
query = form.fields['template'].queryset
|
|
||||||
|
|
||||||
query = query.exclude(id__in=[param.template.id for param in part.parameters.all()])
|
|
||||||
|
|
||||||
form.fields['template'].queryset = query
|
|
||||||
|
|
||||||
except (Part.DoesNotExist, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
return form
|
|
||||||
|
|
||||||
|
|
||||||
class PartParameterEdit(AjaxUpdateView):
|
|
||||||
""" View for editing a PartParameter """
|
|
||||||
|
|
||||||
model = PartParameter
|
|
||||||
form_class = part_forms.EditPartParameterForm
|
|
||||||
ajax_form_title = _('Edit Part Parameter')
|
|
||||||
|
|
||||||
def get_form(self):
|
|
||||||
|
|
||||||
form = super().get_form()
|
|
||||||
|
|
||||||
return form
|
|
||||||
|
|
||||||
|
|
||||||
class PartParameterDelete(AjaxDeleteView):
|
|
||||||
""" View for deleting a PartParameter """
|
|
||||||
|
|
||||||
model = PartParameter
|
|
||||||
ajax_template_name = 'part/param_delete.html'
|
|
||||||
ajax_form_title = _('Delete Part Parameter')
|
|
||||||
|
|
||||||
|
|
||||||
class CategoryDetail(InvenTreeRoleMixin, DetailView):
|
class CategoryDetail(InvenTreeRoleMixin, DetailView):
|
||||||
""" Detail view for PartCategory """
|
""" Detail view for PartCategory """
|
||||||
|
|
||||||
|
@ -100,7 +100,7 @@ class StockTest(TestCase):
|
|||||||
# And there should be *no* items being build
|
# And there should be *no* items being build
|
||||||
self.assertEqual(part.quantity_being_built, 0)
|
self.assertEqual(part.quantity_being_built, 0)
|
||||||
|
|
||||||
build = Build.objects.create(part=part, title='A test build', quantity=1)
|
build = Build.objects.create(reference='12345', part=part, title='A test build', quantity=1)
|
||||||
|
|
||||||
# Add some stock items which are "building"
|
# Add some stock items which are "building"
|
||||||
for i in range(10):
|
for i in range(10):
|
||||||
|
@ -75,7 +75,7 @@
|
|||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
|
||||||
$("#param-table").inventreeTable({
|
$("#param-table").inventreeTable({
|
||||||
url: "{% url 'api-part-param-template-list' %}",
|
url: "{% url 'api-part-parameter-template-list' %}",
|
||||||
queryParams: {
|
queryParams: {
|
||||||
ordering: 'name',
|
ordering: 'name',
|
||||||
},
|
},
|
||||||
|
@ -1,34 +1,72 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load inventree_extras %}
|
{% load inventree_extras %}
|
||||||
|
|
||||||
|
|
||||||
|
function buildFormFields() {
|
||||||
|
return {
|
||||||
|
reference: {
|
||||||
|
prefix: "{% settings_value 'BUILDORDER_REFERENCE_PREFIX' %}",
|
||||||
|
},
|
||||||
|
title: {},
|
||||||
|
part: {},
|
||||||
|
quantity: {},
|
||||||
|
parent: {
|
||||||
|
filters: {
|
||||||
|
part_detail: true,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
batch: {},
|
||||||
|
target_date: {},
|
||||||
|
take_from: {},
|
||||||
|
destination: {},
|
||||||
|
link: {
|
||||||
|
icon: 'fa-link',
|
||||||
|
},
|
||||||
|
issued_by: {
|
||||||
|
icon: 'fa-user',
|
||||||
|
},
|
||||||
|
responsible: {
|
||||||
|
icon: 'fa-users',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function editBuildOrder(pk, options={}) {
|
||||||
|
|
||||||
|
var fields = buildFormFields();
|
||||||
|
|
||||||
|
constructForm(`/api/build/${pk}/`, {
|
||||||
|
fields: fields,
|
||||||
|
reload: true,
|
||||||
|
title: '{% trans "Edit Build Order" %}',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function newBuildOrder(options={}) {
|
function newBuildOrder(options={}) {
|
||||||
/* Launch modal form to create a new BuildOrder.
|
/* Launch modal form to create a new BuildOrder.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
launchModalForm(
|
var fields = buildFormFields();
|
||||||
"{% url 'build-create' %}",
|
|
||||||
{
|
|
||||||
follow: true,
|
|
||||||
data: options.data || {},
|
|
||||||
callback: [
|
|
||||||
{
|
|
||||||
field: 'part',
|
|
||||||
action: function(value) {
|
|
||||||
inventreeGet(
|
|
||||||
`/api/part/${value}/`, {},
|
|
||||||
{
|
|
||||||
success: function(response) {
|
|
||||||
|
|
||||||
//enableField('serial_numbers', response.trackable);
|
if (options.part) {
|
||||||
//clearField('serial_numbers');
|
fields.part.value = options.part;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.quantity) {
|
||||||
|
fields.quantity.value = options.quantity;
|
||||||
}
|
}
|
||||||
);
|
|
||||||
},
|
if (options.parent) {
|
||||||
|
fields.parent.value = options.parent;
|
||||||
}
|
}
|
||||||
],
|
|
||||||
}
|
constructForm(`/api/build/`, {
|
||||||
)
|
fields: fields,
|
||||||
|
follow: true,
|
||||||
|
method: 'POST',
|
||||||
|
title: '{% trans "Create Build Order" %}'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -384,14 +422,10 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
|||||||
var idx = $(this).closest('tr').attr('data-index');
|
var idx = $(this).closest('tr').attr('data-index');
|
||||||
var row = $(table).bootstrapTable('getData')[idx];
|
var row = $(table).bootstrapTable('getData')[idx];
|
||||||
|
|
||||||
// Launch form to create a new build order
|
newBuildOrder({
|
||||||
launchModalForm('{% url "build-create" %}', {
|
|
||||||
follow: true,
|
|
||||||
data: {
|
|
||||||
part: pk,
|
part: pk,
|
||||||
parent: buildId,
|
parent: buildId,
|
||||||
quantity: requiredQuantity(row) - sumAllocations(row),
|
quantity: requiredQuantity(row) - sumAllocations(row),
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1092,13 +1126,9 @@ function loadBuildPartsTable(table, options={}) {
|
|||||||
var idx = $(this).closest('tr').attr('data-index');
|
var idx = $(this).closest('tr').attr('data-index');
|
||||||
var row = $(table).bootstrapTable('getData')[idx];
|
var row = $(table).bootstrapTable('getData')[idx];
|
||||||
|
|
||||||
// Launch form to create a new build order
|
newBuildOrder({
|
||||||
launchModalForm('{% url "build-create" %}', {
|
|
||||||
follow: true,
|
|
||||||
data: {
|
|
||||||
part: pk,
|
part: pk,
|
||||||
parent: options.build,
|
parent: options.build,
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -511,6 +511,10 @@ function insertConfirmButton(options) {
|
|||||||
*/
|
*/
|
||||||
function submitFormData(fields, options) {
|
function submitFormData(fields, options) {
|
||||||
|
|
||||||
|
// Immediately disable the "submit" button,
|
||||||
|
// to prevent the form being submitted multiple times!
|
||||||
|
$(options.modal).find('#modal-form-submit').prop('disabled', true);
|
||||||
|
|
||||||
// Form data to be uploaded to the server
|
// Form data to be uploaded to the server
|
||||||
// Only used if file / image upload is required
|
// Only used if file / image upload is required
|
||||||
var form_data = new FormData();
|
var form_data = new FormData();
|
||||||
@ -728,11 +732,31 @@ function handleFormSuccess(response, options) {
|
|||||||
|
|
||||||
// Close the modal
|
// Close the modal
|
||||||
if (!options.preventClose) {
|
if (!options.preventClose) {
|
||||||
// TODO: Actually just *delete* the modal,
|
// Note: The modal will be deleted automatically after closing
|
||||||
// rather than hiding it!!
|
|
||||||
$(options.modal).modal('hide');
|
$(options.modal).modal('hide');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Display any required messages
|
||||||
|
// Should we show alerts immediately or cache them?
|
||||||
|
var cache = (options.follow && response.url) || options.redirect || options.reload;
|
||||||
|
|
||||||
|
// Display any messages
|
||||||
|
if (response && response.success) {
|
||||||
|
showAlertOrCache("alert-success", response.success, cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response && response.info) {
|
||||||
|
showAlertOrCache("alert-info", response.info, cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response && response.warning) {
|
||||||
|
showAlertOrCache("alert-warning", response.warning, cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response && response.danger) {
|
||||||
|
showAlertOrCache("alert-danger", response.danger, cache);
|
||||||
|
}
|
||||||
|
|
||||||
if (options.onSuccess) {
|
if (options.onSuccess) {
|
||||||
// Callback function
|
// Callback function
|
||||||
options.onSuccess(response, options);
|
options.onSuccess(response, options);
|
||||||
@ -778,6 +802,9 @@ function clearFormErrors(options) {
|
|||||||
*/
|
*/
|
||||||
function handleFormErrors(errors, fields, options) {
|
function handleFormErrors(errors, fields, options) {
|
||||||
|
|
||||||
|
// Reset the status of the "submit" button
|
||||||
|
$(options.modal).find('#modal-form-submit').prop('disabled', false);
|
||||||
|
|
||||||
// Remove any existing error messages from the form
|
// Remove any existing error messages from the form
|
||||||
clearFormErrors(options);
|
clearFormErrors(options);
|
||||||
|
|
||||||
@ -1201,11 +1228,21 @@ function renderModelData(name, model, data, parameters, options) {
|
|||||||
case 'partcategory':
|
case 'partcategory':
|
||||||
renderer = renderPartCategory;
|
renderer = renderPartCategory;
|
||||||
break;
|
break;
|
||||||
|
case 'partparametertemplate':
|
||||||
|
renderer = renderPartParameterTemplate;
|
||||||
|
break;
|
||||||
case 'supplierpart':
|
case 'supplierpart':
|
||||||
renderer = renderSupplierPart;
|
renderer = renderSupplierPart;
|
||||||
break;
|
break;
|
||||||
|
case 'build':
|
||||||
|
renderer = renderBuild;
|
||||||
|
break;
|
||||||
case 'owner':
|
case 'owner':
|
||||||
renderer = renderOwner;
|
renderer = renderOwner;
|
||||||
|
break;
|
||||||
|
case 'user':
|
||||||
|
renderer = renderUser;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -105,6 +105,61 @@ function printStockLocationLabels(locations, options={}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function printPartLabels(parts, options={}) {
|
||||||
|
/**
|
||||||
|
* Print labels for the provided parts
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (parts.length == 0) {
|
||||||
|
showAlertDialog(
|
||||||
|
'{% trans "Select Parts" %}',
|
||||||
|
'{% trans "Part(s) must be selected before printing labels" %}',
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request available labels from the server
|
||||||
|
inventreeGet(
|
||||||
|
'{% url "api-part-label-list" %}',
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
parts: parts,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
success: function(response) {
|
||||||
|
|
||||||
|
if (response.length == 0) {
|
||||||
|
showAlertDialog(
|
||||||
|
'{% trans "No Labels Found" %}',
|
||||||
|
'{% trans "No labels found which match the selected part(s)" %}',
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select label to print
|
||||||
|
selectLabel(
|
||||||
|
response,
|
||||||
|
parts,
|
||||||
|
{
|
||||||
|
success: function(pk) {
|
||||||
|
var url = `/api/label/part/${pk}/print/?`;
|
||||||
|
|
||||||
|
parts.forEach(function(part) {
|
||||||
|
url += `parts[]=${part}&`;
|
||||||
|
});
|
||||||
|
|
||||||
|
window.location.href = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function selectLabel(labels, items, options={}) {
|
function selectLabel(labels, items, options={}) {
|
||||||
/**
|
/**
|
||||||
* Present the user with the available labels,
|
* Present the user with the available labels,
|
||||||
|
@ -83,8 +83,6 @@ function createNewModal(options={}) {
|
|||||||
|
|
||||||
// Capture "enter" key input
|
// Capture "enter" key input
|
||||||
$(modal_name).on('keydown', 'input', function(event) {
|
$(modal_name).on('keydown', 'input', function(event) {
|
||||||
|
|
||||||
|
|
||||||
if (event.keyCode == 13) {
|
if (event.keyCode == 13) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
// Simulate a click on the 'Submit' button
|
// Simulate a click on the 'Submit' button
|
||||||
|
@ -70,6 +70,27 @@ function renderStockLocation(name, data, parameters, options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function renderBuild(name, data, parameters, options) {
|
||||||
|
|
||||||
|
var image = '';
|
||||||
|
|
||||||
|
if (data.part_detail && data.part_detail.thumbnail) {
|
||||||
|
image = data.part_detail.thumbnail;
|
||||||
|
} else {
|
||||||
|
image = `/static/img/blank_image.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
var html = `<img src='${image}' class='select2-thumbnail'>`;
|
||||||
|
|
||||||
|
html += `<span><b>${data.reference}</b></span> - ${data.quantity} x ${data.part_detail.full_name}`;
|
||||||
|
html += `<span class='float-right'>{% trans "Build ID" %}: ${data.pk}</span>`;
|
||||||
|
|
||||||
|
html += `<p><i>${data.title}</i></p>`;
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Renderer for "Part" model
|
// Renderer for "Part" model
|
||||||
function renderPart(name, data, parameters, options) {
|
function renderPart(name, data, parameters, options) {
|
||||||
|
|
||||||
@ -92,6 +113,18 @@ function renderPart(name, data, parameters, options) {
|
|||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Renderer for "User" model
|
||||||
|
function renderUser(name, data, parameters, options) {
|
||||||
|
|
||||||
|
var html = `<span>${data.username}</span>`;
|
||||||
|
|
||||||
|
if (data.first_name && data.last_name) {
|
||||||
|
html += ` - <i>${data.first_name} ${data.last_name}</i>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Renderer for "Owner" model
|
// Renderer for "Owner" model
|
||||||
function renderOwner(name, data, parameters, options) {
|
function renderOwner(name, data, parameters, options) {
|
||||||
@ -133,6 +166,14 @@ function renderPartCategory(name, data, parameters, options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function renderPartParameterTemplate(name, data, parameters, options) {
|
||||||
|
|
||||||
|
var html = `<span>${data.name} - [${data.units}]</span>`;
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Rendered for "SupplierPart" model
|
// Rendered for "SupplierPart" model
|
||||||
function renderSupplierPart(name, data, parameters, options) {
|
function renderSupplierPart(name, data, parameters, options) {
|
||||||
|
|
||||||
|
@ -220,6 +220,107 @@ function loadSimplePartTable(table, url, options={}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function loadPartParameterTable(table, url, options) {
|
||||||
|
|
||||||
|
var params = options.params || {};
|
||||||
|
|
||||||
|
// Load filters
|
||||||
|
var filters = loadTableFilters("part-parameters");
|
||||||
|
|
||||||
|
for (var key in params) {
|
||||||
|
filters[key] = params[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupFilterLsit("#part-parameters", $(table));
|
||||||
|
|
||||||
|
$(table).inventreeTable({
|
||||||
|
url: url,
|
||||||
|
original: params,
|
||||||
|
queryParams: filters,
|
||||||
|
name: 'partparameters',
|
||||||
|
groupBy: false,
|
||||||
|
formatNoMatches: function() { return '{% trans "No parameters found" %}'; },
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
checkbox: true,
|
||||||
|
switchable: false,
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'name',
|
||||||
|
title: '{% trans "Name" %}',
|
||||||
|
switchable: false,
|
||||||
|
sortable: true,
|
||||||
|
formatter: function(value, row) {
|
||||||
|
return row.template_detail.name;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'data',
|
||||||
|
title: '{% trans "Value" %}',
|
||||||
|
switchable: false,
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'units',
|
||||||
|
title: '{% trans "Units" %}',
|
||||||
|
switchable: true,
|
||||||
|
sortable: true,
|
||||||
|
formatter: function(value, row) {
|
||||||
|
return row.template_detail.units;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'actions',
|
||||||
|
title: '',
|
||||||
|
switchable: false,
|
||||||
|
sortable: false,
|
||||||
|
formatter: function(value, row) {
|
||||||
|
var pk = row.pk;
|
||||||
|
|
||||||
|
var html = `<div class='btn-group float-right' role='group'>`;
|
||||||
|
|
||||||
|
html += makeIconButton('fa-edit icon-blue', 'button-parameter-edit', pk, '{% trans "Edit parameter" %}');
|
||||||
|
html += makeIconButton('fa-trash-alt icon-red', 'button-parameter-delete', pk, '{% trans "Delete parameter" %}');
|
||||||
|
|
||||||
|
html += `</div>`;
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
onPostBody: function() {
|
||||||
|
// Setup button callbacks
|
||||||
|
$(table).find('.button-parameter-edit').click(function() {
|
||||||
|
var pk = $(this).attr('pk');
|
||||||
|
|
||||||
|
constructForm(`/api/part/parameter/${pk}/`, {
|
||||||
|
fields: {
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
title: '{% trans "Edit Parameter" %}',
|
||||||
|
onSuccess: function() {
|
||||||
|
$(table).bootstrapTable('refresh');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$(table).find('.button-parameter-delete').click(function() {
|
||||||
|
var pk = $(this).attr('pk');
|
||||||
|
|
||||||
|
constructForm(`/api/part/parameter/${pk}/`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
title: '{% trans "Delete Parameter" %}',
|
||||||
|
onSuccess: function() {
|
||||||
|
$(table).bootstrapTable('refresh');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function loadParametricPartTable(table, options={}) {
|
function loadParametricPartTable(table, options={}) {
|
||||||
/* Load parametric table for part parameters
|
/* Load parametric table for part parameters
|
||||||
*
|
*
|
||||||
|
@ -87,6 +87,7 @@ class RuleSet(models.Model):
|
|||||||
'company_supplierpart',
|
'company_supplierpart',
|
||||||
'company_manufacturerpart',
|
'company_manufacturerpart',
|
||||||
'company_manufacturerpartparameter',
|
'company_manufacturerpartparameter',
|
||||||
|
'label_partlabel',
|
||||||
],
|
],
|
||||||
'stock_location': [
|
'stock_location': [
|
||||||
'stock_stocklocation',
|
'stock_stocklocation',
|
||||||
|
Loading…
Reference in New Issue
Block a user