Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver 2021-07-09 10:41:09 +10:00
commit 5b1f54a842
37 changed files with 890 additions and 413 deletions

View File

@ -153,6 +153,11 @@ class InvenTreeMetadata(SimpleMetadata):
if 'default' not in field_info and not field.default == empty:
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
if field_info['type'] == 'field' and not field_info['read_only']:
@ -166,7 +171,12 @@ class InvenTreeMetadata(SimpleMetadata):
if model:
# Mark this field as "related", and point to the URL where we can get the data!
field_info['type'] = 'related field'
field_info['api_url'] = model.get_api_url()
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

View File

@ -5,11 +5,13 @@ JSON API for the Build app
# -*- coding: utf-8 -*-
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 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.helpers import str2bool, isNull
@ -19,6 +21,36 @@ from .models import Build, BuildItem, BuildOrderAttachment
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):
""" API endpoint for accessing a list of Build objects.
@ -28,6 +60,7 @@ class BuildList(generics.ListCreateAPIView):
queryset = Build.objects.all()
serializer_class = BuildSerializer
filterset_class = BuildFilter
filter_backends = [
DjangoFilterBackend,
@ -97,34 +130,6 @@ class BuildList(generics.ListCreateAPIView):
except (ValueError, Build.DoesNotExist):
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?
part = params.get('part', None)

View 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'),
),
]

View File

@ -21,6 +21,7 @@ from django.core.validators import MinValueValidator
from markdownx.models import MarkdownxField
from mptt.models import MPTTModel, TreeForeignKey
from mptt.exceptions import InvalidMove
from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode
from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode
@ -37,6 +38,35 @@ from part import models as PartModels
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):
""" 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
"""
OVERDUE_FILTER = Q(status__in=BuildStatus.ACTIVE_CODES) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date())
@staticmethod
def get_api_url():
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:
verbose_name = _("Build Order")
@ -130,6 +169,7 @@ class Build(MPTTModel):
blank=False,
help_text=_('Build Order Reference'),
verbose_name=_('Reference'),
default=get_next_build_number,
validators=[
validate_build_order_reference
]

View File

@ -62,7 +62,7 @@ class BuildSerializer(InvenTreeModelSerializer):
return queryset
def __init__(self, *args, **kwargs):
part_detail = kwargs.pop('part_detail', False)
part_detail = kwargs.pop('part_detail', True)
super().__init__(*args, **kwargs)
@ -75,9 +75,12 @@ class BuildSerializer(InvenTreeModelSerializer):
'pk',
'url',
'title',
'batch',
'creation_date',
'completed',
'completion_date',
'destination',
'parent',
'part',
'part_detail',
'overdue',
@ -87,6 +90,7 @@ class BuildSerializer(InvenTreeModelSerializer):
'status',
'status_text',
'target_date',
'take_from',
'notes',
'link',
'issued_by',

View File

@ -196,10 +196,7 @@ src="{% static 'img/blank_image.png' %}"
});
$("#build-edit").click(function () {
launchModalForm("{% url 'build-edit' build.id %}",
{
reload: true
});
editBuildOrder({{ build.pk }});
});
$("#build-cancel").click(function() {

View File

@ -5,10 +5,11 @@ from django.test import TestCase
from django.core.exceptions import ValidationError
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 part.models import Part, BomItem
from InvenTree import status_codes as status
class BuildTest(TestCase):
@ -80,8 +81,14 @@ class BuildTest(TestCase):
quantity=2
)
ref = get_next_build_number()
if ref is None:
ref = "0001"
# Create a "Build" object to make 10x objects
self.build = Build.objects.create(
reference=ref,
title="This is a build",
part=self.assembly,
quantity=10

View File

@ -252,23 +252,6 @@ class TestBuildViews(TestCase):
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):
""" Test the part allocation view for a Build """

View File

@ -7,7 +7,6 @@ from django.conf.urls import url, include
from . import views
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'^cancel/', views.BuildCancel.as_view(), name='build-cancel'),
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(r'new/', views.BuildCreate.as_view(), name='build-create'),
url(r'^(?P<pk>\d+)/', include(build_detail_urls)),
url(r'.*$', views.BuildIndex.as_view(), name='build-index'),

View File

@ -667,126 +667,6 @@ class BuildAllocate(InvenTreeRoleMixin, DetailView):
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):
""" View to delete a build """

View File

@ -3,7 +3,7 @@ from __future__ import unicode_literals
from django.contrib import admin
from .models import StockItemLabel, StockLocationLabel
from .models import StockItemLabel, StockLocationLabel, PartLabel
class LabelAdmin(admin.ModelAdmin):
@ -13,3 +13,4 @@ class LabelAdmin(admin.ModelAdmin):
admin.site.register(StockItemLabel, LabelAdmin)
admin.site.register(StockLocationLabel, LabelAdmin)
admin.site.register(PartLabel, LabelAdmin)

View File

@ -15,9 +15,10 @@ import InvenTree.helpers
import common.models
from stock.models import StockItem, StockLocation
from part.models import Part
from .models import StockItemLabel, StockLocationLabel
from .serializers import StockItemLabelSerializer, StockLocationLabelSerializer
from .models import StockItemLabel, StockLocationLabel, PartLabel
from .serializers import StockItemLabelSerializer, StockLocationLabelSerializer, PartLabelSerializer
class LabelListView(generics.ListAPIView):
@ -132,6 +133,7 @@ class StockItemLabelMixin:
for key in ['item', 'item[]', 'items', 'items[]']:
if key in params:
items = params.getlist(key, [])
break
valid_ids = []
@ -376,6 +378,112 @@ class StockLocationLabelPrint(generics.RetrieveAPIView, StockLocationLabelMixin,
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 = [
# Stock item labels
@ -401,4 +509,16 @@ label_api_urls = [
# List view
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'),
])),
]

View File

@ -37,6 +37,7 @@ class LabelConfig(AppConfig):
if canAppAccessDatabase():
self.create_stock_item_labels()
self.create_stock_location_labels()
self.create_part_labels()
def create_stock_item_labels(self):
"""
@ -65,7 +66,7 @@ class LabelConfig(AppConfig):
)
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)
labels = [
@ -109,7 +110,6 @@ class LabelConfig(AppConfig):
logger.info(f"Copying label template '{dst_file}'")
shutil.copyfile(src_file, dst_file)
try:
# Check if a label matching the template already exists
if StockItemLabel.objects.filter(label=filename).exists():
continue
@ -125,8 +125,6 @@ class LabelConfig(AppConfig):
width=label['width'],
height=label['height'],
)
except:
pass
def create_stock_location_labels(self):
"""
@ -155,7 +153,7 @@ class LabelConfig(AppConfig):
)
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)
labels = [
@ -206,7 +204,6 @@ class LabelConfig(AppConfig):
logger.info(f"Copying label template '{dst_file}'")
shutil.copyfile(src_file, dst_file)
try:
# Check if a label matching the template already exists
if StockLocationLabel.objects.filter(label=filename).exists():
continue
@ -222,5 +219,88 @@ class LabelConfig(AppConfig):
width=label['width'],
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:
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'],
)

View 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'),
),
]

View File

@ -25,6 +25,8 @@ from InvenTree.helpers import validateFilterString, normalize
import common.models
import stock.models
import part.models
try:
from django_weasyprint import WeasyTemplateResponseMixin
@ -59,6 +61,13 @@ def validate_stock_location_filters(filters):
return filters
def validate_part_filters(filters):
filters = validateFilterString(filters, model=part.models.Part)
return filters
class WeasyprintLabelMixin(WeasyTemplateResponseMixin):
"""
Class for rendering a label to a PDF
@ -246,10 +255,11 @@ class StockItemLabel(LabelTemplate):
filters = models.CharField(
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'),
validators=[
validate_stock_item_filters]
validate_stock_item_filters
]
)
def matches_stock_item(self, item):
@ -335,3 +345,57 @@ class StockLocationLabel(LabelTemplate):
'location': location,
'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),
}

View File

@ -4,7 +4,7 @@ from __future__ import unicode_literals
from InvenTree.serializers import InvenTreeModelSerializer
from InvenTree.serializers import InvenTreeAttachmentSerializerField
from .models import StockItemLabel, StockLocationLabel
from .models import StockItemLabel, StockLocationLabel, PartLabel
class StockItemLabelSerializer(InvenTreeModelSerializer):
@ -43,3 +43,22 @@ class StockLocationLabelSerializer(InvenTreeModelSerializer):
'filters',
'enabled',
]
class PartLabelSerializer(InvenTreeModelSerializer):
"""
Serializes a PartLabel object
"""
label = InvenTreeAttachmentSerializerField(required=True)
class Meta:
model = PartLabel
fields = [
'pk',
'name',
'description',
'label',
'filters',
'enabled',
]

View 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 %}

View File

@ -43,8 +43,10 @@ def get_next_po_number():
attempts = set([order.reference])
reference = order.reference
while 1:
reference = increment(order.reference)
reference = increment(reference)
if reference in attempts:
# Escape infinite recursion
@ -70,8 +72,10 @@ def get_next_so_number():
attempts = set([order.reference])
reference = order.reference
while 1:
reference = increment(order.reference)
reference = increment(reference)
if reference in attempts:
# Escape infinite recursion

View File

@ -425,18 +425,18 @@ class PartFilter(rest_filters.FilterSet):
else:
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
ipn = rest_filters.CharFilter(
IPN = rest_filters.CharFilter(
label='Filter by exact IPN (internal part number)',
field_name='IPN',
lookup_expr="iexact"
)
# Regex match for IPN
ipn_regex = rest_filters.CharFilter(
label='Filter by regex on IPN (internal part number) field',
field_name='IPN', lookup_expr='iregex'
)
IPN_regex = rest_filters.CharFilter(label='Filter by regex on IPN (internal part number)', field_name='IPN', lookup_expr='iregex')
# low_stock filter
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
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'^.*$', PartParameterList.as_view(), name='api-part-param-list'),
url(r'^(?P<pk>\d+)/', PartParameterDetail.as_view(), name='api-part-parameter-detail'),
url(r'^.*$', PartParameterList.as_view(), name='api-part-parameter-list'),
])),
url(r'^thumbs/', include([

View File

@ -2164,7 +2164,7 @@ class PartParameterTemplate(models.Model):
@staticmethod
def get_api_url():
return reverse('api-part-param-template-list')
return reverse('api-part-parameter-template-list')
def __str__(self):
s = str(self.name)
@ -2205,7 +2205,7 @@ class PartParameter(models.Model):
@staticmethod
def get_api_url():
return reverse('api-part-param-list')
return reverse('api-part-parameter-list')
def __str__(self):
# String representation of a PartParameter (used in the admin interface)

View File

@ -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):
""" 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):
""" Serializer for PartCategoryParameterTemplate """

View File

@ -34,9 +34,7 @@
{{ block.super }}
$("#start-build").click(function() {
newBuildOrder({
data: {
part: {{ part.id }},
}
part: {{ part.pk }},
});
});

View File

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

View File

@ -21,54 +21,43 @@
</div>
</div>
<table id='param-table' class='table table-condensed table-striped' data-toolbar='#button-toolbar'>
<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>
<table id='parameter-table' class='table table-condensed table-striped' data-toolbar="#button-toolbar"></table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
loadPartParameterTable(
'#parameter-table',
'{% url "api-part-parameter-list" %}',
{
params: {
part: {{ part.pk }},
}
}
);
$('#param-table').inventreeTable({
});
{% if roles.part.add %}
$('#param-create').click(function() {
launchModalForm("{% url 'part-param-create' %}?part={{ part.id }}", {
reload: true,
secondary: [{
field: 'template',
label: '{% trans "New Template" %}',
title: '{% trans "Create New Parameter Template" %}',
url: "{% url 'part-param-template-create' %}"
}],
constructForm('{% url "api-part-parameter-list" %}', {
method: 'POST',
fields: {
part: {
value: {{ part.pk }},
hidden: true,
},
template: {},
data: {},
},
title: '{% trans "Add Parameter" %}',
onSuccess: function() {
$('#parameter-table').bootstrapTable('refresh');
}
});
});
{% endif %}

View File

@ -268,6 +268,10 @@
);
});
$('#print-label').click(function() {
printPartLabels([{{ part.pk }}]);
});
$("#part-count").click(function() {
launchModalForm("/stock/adjust/", {
data: {

View File

@ -805,7 +805,7 @@ class PartParameterTest(InvenTreeAPITestCase):
Test for listing part parameters
"""
url = reverse('api-part-param-list')
url = reverse('api-part-parameter-list')
response = self.client.get(url, format='json')
@ -838,7 +838,7 @@ class PartParameterTest(InvenTreeAPITestCase):
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(
url,
@ -860,7 +860,7 @@ class PartParameterTest(InvenTreeAPITestCase):
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)

View File

@ -33,10 +33,6 @@ part_parameter_urls = [
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+)/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 = [

View File

@ -32,7 +32,7 @@ from rapidfuzz import fuzz
from decimal import Decimal, InvalidOperation
from .models import PartCategory, Part, PartRelated
from .models import PartParameterTemplate, PartParameter
from .models import PartParameterTemplate
from .models import PartCategoryParameterTemplate
from .models import BomItem
from .models import match_part_names
@ -2257,78 +2257,6 @@ class PartParameterTemplateDelete(AjaxDeleteView):
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):
""" Detail view for PartCategory """

View File

@ -100,7 +100,7 @@ class StockTest(TestCase):
# And there should be *no* items being build
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"
for i in range(10):

View File

@ -75,7 +75,7 @@
{{ block.super }}
$("#param-table").inventreeTable({
url: "{% url 'api-part-param-template-list' %}",
url: "{% url 'api-part-parameter-template-list' %}",
queryParams: {
ordering: 'name',
},

View File

@ -1,34 +1,72 @@
{% load i18n %}
{% 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={}) {
/* Launch modal form to create a new BuildOrder.
*/
launchModalForm(
"{% url 'build-create' %}",
{
follow: true,
data: options.data || {},
callback: [
{
field: 'part',
action: function(value) {
inventreeGet(
`/api/part/${value}/`, {},
{
success: function(response) {
var fields = buildFormFields();
//enableField('serial_numbers', response.trackable);
//clearField('serial_numbers');
if (options.part) {
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 row = $(table).bootstrapTable('getData')[idx];
// Launch form to create a new build order
launchModalForm('{% url "build-create" %}', {
follow: true,
data: {
newBuildOrder({
part: pk,
parent: buildId,
quantity: requiredQuantity(row) - sumAllocations(row),
}
});
});
@ -1092,13 +1126,9 @@ function loadBuildPartsTable(table, options={}) {
var idx = $(this).closest('tr').attr('data-index');
var row = $(table).bootstrapTable('getData')[idx];
// Launch form to create a new build order
launchModalForm('{% url "build-create" %}', {
follow: true,
data: {
newBuildOrder({
part: pk,
parent: options.build,
}
});
});
}

View File

@ -511,6 +511,10 @@ function insertConfirmButton(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
// Only used if file / image upload is required
var form_data = new FormData();
@ -728,11 +732,31 @@ function handleFormSuccess(response, options) {
// Close the modal
if (!options.preventClose) {
// TODO: Actually just *delete* the modal,
// rather than hiding it!!
// Note: The modal will be deleted automatically after closing
$(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) {
// Callback function
options.onSuccess(response, options);
@ -778,6 +802,9 @@ function clearFormErrors(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
clearFormErrors(options);
@ -1201,11 +1228,21 @@ function renderModelData(name, model, data, parameters, options) {
case 'partcategory':
renderer = renderPartCategory;
break;
case 'partparametertemplate':
renderer = renderPartParameterTemplate;
break;
case 'supplierpart':
renderer = renderSupplierPart;
break;
case 'build':
renderer = renderBuild;
break;
case 'owner':
renderer = renderOwner;
break;
case 'user':
renderer = renderUser;
break;
default:
break;
}

View File

@ -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={}) {
/**
* Present the user with the available labels,

View File

@ -83,8 +83,6 @@ function createNewModal(options={}) {
// Capture "enter" key input
$(modal_name).on('keydown', 'input', function(event) {
if (event.keyCode == 13) {
event.preventDefault();
// Simulate a click on the 'Submit' button

View File

@ -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
function renderPart(name, data, parameters, options) {
@ -92,6 +113,18 @@ function renderPart(name, data, parameters, options) {
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
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
function renderSupplierPart(name, data, parameters, options) {

View File

@ -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={}) {
/* Load parametric table for part parameters
*

View File

@ -87,6 +87,7 @@ class RuleSet(models.Model):
'company_supplierpart',
'company_manufacturerpart',
'company_manufacturerpartparameter',
'label_partlabel',
],
'stock_location': [
'stock_stocklocation',