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
a2314074d3
@ -199,7 +199,7 @@ class AjaxUpdateView(AjaxMixin, UpdateView):
|
||||
|
||||
form = self.get_form()
|
||||
|
||||
return self.renderJsonResponse(request, form)
|
||||
return self.renderJsonResponse(request, form, context=self.get_context_data())
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
""" Respond to POST request.
|
||||
|
@ -2,21 +2,33 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.contrib import admin
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
|
||||
from .models import Build
|
||||
from .models import Build, BuildItem
|
||||
|
||||
|
||||
class BuildAdmin(admin.ModelAdmin):
|
||||
class BuildAdmin(ImportExportModelAdmin):
|
||||
|
||||
list_display = ('part',
|
||||
'status',
|
||||
'batch',
|
||||
'quantity',
|
||||
'creation_date',
|
||||
'completion_date',
|
||||
'title',
|
||||
'notes',
|
||||
)
|
||||
list_display = (
|
||||
'part',
|
||||
'status',
|
||||
'batch',
|
||||
'quantity',
|
||||
'creation_date',
|
||||
'completion_date',
|
||||
'title',
|
||||
'notes',
|
||||
)
|
||||
|
||||
|
||||
class BuildItemAdmin(admin.ModelAdmin):
|
||||
|
||||
list_display = (
|
||||
'build',
|
||||
'stock_item',
|
||||
'quantity'
|
||||
)
|
||||
|
||||
|
||||
admin.site.register(Build, BuildAdmin)
|
||||
admin.site.register(BuildItem, BuildItemAdmin)
|
||||
|
@ -9,10 +9,10 @@ from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework import filters
|
||||
from rest_framework import generics, permissions
|
||||
|
||||
from django.conf.urls import url
|
||||
from django.conf.urls import url, include
|
||||
|
||||
from .models import Build
|
||||
from .serializers import BuildSerializer
|
||||
from .models import Build, BuildItem
|
||||
from .serializers import BuildSerializer, BuildItemSerializer
|
||||
|
||||
|
||||
class BuildList(generics.ListCreateAPIView):
|
||||
@ -40,6 +40,50 @@ class BuildList(generics.ListCreateAPIView):
|
||||
]
|
||||
|
||||
|
||||
build_api_urls = [
|
||||
url(r'^.*$', BuildList.as_view(), name='api-build-list')
|
||||
class BuildItemList(generics.ListCreateAPIView):
|
||||
""" API endpoint for accessing a list of BuildItem objects
|
||||
|
||||
- GET: Return list of objects
|
||||
- POST: Create a new BuildItem object
|
||||
"""
|
||||
|
||||
serializer_class = BuildItemSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
""" Override the queryset method,
|
||||
to allow filtering by stock_item.part
|
||||
"""
|
||||
|
||||
# Does the user wish to filter by part?
|
||||
part_pk = self.request.query_params.get('part', None)
|
||||
|
||||
query = BuildItem.objects.all()
|
||||
|
||||
if part_pk:
|
||||
query = query.filter(stock_item__part=part_pk)
|
||||
|
||||
return query
|
||||
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticatedOrReadOnly,
|
||||
]
|
||||
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
]
|
||||
|
||||
filter_fields = [
|
||||
'build',
|
||||
'stock_item'
|
||||
]
|
||||
|
||||
|
||||
build_item_api_urls = [
|
||||
url('^.*$', BuildItemList.as_view(), name='api-build-item-list'),
|
||||
]
|
||||
|
||||
build_api_urls = [
|
||||
url(r'^item/?', include(build_item_api_urls)),
|
||||
|
||||
url(r'^.*$', BuildList.as_view(), name='api-build-list'),
|
||||
]
|
||||
|
@ -6,8 +6,9 @@ Django Forms for interacting with Build objects
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from InvenTree.forms import HelperForm
|
||||
|
||||
from .models import Build
|
||||
from django import forms
|
||||
from .models import Build, BuildItem
|
||||
from stock.models import StockLocation
|
||||
|
||||
|
||||
class EditBuildForm(HelperForm):
|
||||
@ -21,7 +22,36 @@ class EditBuildForm(HelperForm):
|
||||
'part',
|
||||
'quantity',
|
||||
'batch',
|
||||
'URL',
|
||||
'notes',
|
||||
# 'status',
|
||||
# 'completion_date',
|
||||
]
|
||||
|
||||
|
||||
class CompleteBuildForm(HelperForm):
|
||||
""" Form for marking a Build as complete """
|
||||
|
||||
location = forms.ModelChoiceField(
|
||||
queryset=StockLocation.objects.all(),
|
||||
help_text='Location of completed parts',
|
||||
)
|
||||
|
||||
confirm = forms.BooleanField(required=False, help_text='Confirm build submission')
|
||||
|
||||
class Meta:
|
||||
model = Build
|
||||
fields = [
|
||||
'location',
|
||||
'confirm'
|
||||
]
|
||||
|
||||
|
||||
class EditBuildItemForm(HelperForm):
|
||||
""" Form for adding a new BuildItem to a Build """
|
||||
|
||||
class Meta:
|
||||
model = BuildItem
|
||||
fields = [
|
||||
'build',
|
||||
'stock_item',
|
||||
'quantity',
|
||||
]
|
||||
|
25
InvenTree/build/migrations/0003_builditemallocation.py
Normal file
25
InvenTree/build/migrations/0003_builditemallocation.py
Normal file
@ -0,0 +1,25 @@
|
||||
# Generated by Django 2.2 on 2019-04-29 12:14
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stock', '0009_auto_20190428_0841'),
|
||||
('build', '0002_auto_20190412_2030'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='BuildItemAllocation',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('quantity', models.PositiveIntegerField(default=1, help_text='Stock quantity to allocate to build', validators=[django.core.validators.MinValueValidator(1)])),
|
||||
('build', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allocated_stock', to='build.Build')),
|
||||
('stock', models.ForeignKey(help_text='Stock Item to allocate to build', on_delete=django.db.models.deletion.CASCADE, related_name='allocations', to='stock.StockItem')),
|
||||
],
|
||||
),
|
||||
]
|
18
InvenTree/build/migrations/0004_build_url.py
Normal file
18
InvenTree/build/migrations/0004_build_url.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2 on 2019-04-29 12:18
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('build', '0003_builditemallocation'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='build',
|
||||
name='URL',
|
||||
field=models.URLField(blank=True, help_text='Link to external URL'),
|
||||
),
|
||||
]
|
18
InvenTree/build/migrations/0005_auto_20190429_2229.py
Normal file
18
InvenTree/build/migrations/0005_auto_20190429_2229.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2 on 2019-04-29 12:29
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stock', '0009_auto_20190428_0841'),
|
||||
('build', '0004_build_url'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameModel(
|
||||
old_name='BuildItemAllocation',
|
||||
new_name='BuildItem',
|
||||
),
|
||||
]
|
18
InvenTree/build/migrations/0006_auto_20190429_2233.py
Normal file
18
InvenTree/build/migrations/0006_auto_20190429_2233.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2 on 2019-04-29 12:33
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('build', '0005_auto_20190429_2229'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='builditem',
|
||||
old_name='stock',
|
||||
new_name='stock_item',
|
||||
),
|
||||
]
|
18
InvenTree/build/migrations/0007_auto_20190429_2255.py
Normal file
18
InvenTree/build/migrations/0007_auto_20190429_2255.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2 on 2019-04-29 12:55
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stock', '0009_auto_20190428_0841'),
|
||||
('build', '0006_auto_20190429_2233'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name='builditem',
|
||||
unique_together={('build', 'stock_item')},
|
||||
),
|
||||
]
|
25
InvenTree/build/migrations/0008_auto_20190501_2344.py
Normal file
25
InvenTree/build/migrations/0008_auto_20190501_2344.py
Normal file
@ -0,0 +1,25 @@
|
||||
# Generated by Django 2.2 on 2019-05-01 13:44
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('build', '0007_auto_20190429_2255'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='build',
|
||||
name='status',
|
||||
field=models.PositiveIntegerField(choices=[(10, 'Pending'), (30, 'Cancelled'), (40, 'Complete')], default=10, validators=[django.core.validators.MinValueValidator(0)]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='builditem',
|
||||
name='build',
|
||||
field=models.ForeignKey(help_text='Build to allocate parts', on_delete=django.db.models.deletion.CASCADE, related_name='allocated_stock', to='build.Build'),
|
||||
),
|
||||
]
|
@ -5,17 +5,79 @@ Build database model definitions
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from django.urls import reverse
|
||||
from django.db import models
|
||||
from django.db import models, transaction
|
||||
from django.core.validators import MinValueValidator
|
||||
|
||||
from stock.models import StockItem
|
||||
|
||||
|
||||
class Build(models.Model):
|
||||
""" A Build object organises the creation of new parts from the component parts.
|
||||
|
||||
Attributes:
|
||||
part: The part to be built (from component BOM items)
|
||||
title: Brief title describing the build (required)
|
||||
quantity: Number of units to be built
|
||||
status: Build status code
|
||||
batch: Batch code transferred to build parts (optional)
|
||||
creation_date: Date the build was created (auto)
|
||||
completion_date: Date the build was completed
|
||||
URL: External URL for extra information
|
||||
notes: Text notes
|
||||
"""
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
""" Called when the Build model is saved to the database.
|
||||
|
||||
If this is a new Build, try to allocate StockItem objects automatically.
|
||||
|
||||
- If there is only one StockItem for a Part, use that one.
|
||||
- If there are multiple StockItem objects, leave blank and let the user decide
|
||||
"""
|
||||
|
||||
allocate_parts = False
|
||||
|
||||
# If there is no PK yet, then this is the first time the Build has been saved
|
||||
if not self.pk:
|
||||
allocate_parts = True
|
||||
|
||||
# Save this Build first
|
||||
super(Build, self).save(*args, **kwargs)
|
||||
|
||||
if allocate_parts:
|
||||
for item in self.part.bom_items.all():
|
||||
part = item.sub_part
|
||||
# Number of parts required for this build
|
||||
q_req = item.quantity * self.quantity
|
||||
|
||||
stock = StockItem.objects.filter(part=part)
|
||||
|
||||
if len(stock) == 1:
|
||||
stock_item = stock[0]
|
||||
|
||||
# Are there any parts available?
|
||||
if stock_item.quantity > 0:
|
||||
# If there are not enough parts, reduce the amount we will take
|
||||
if stock_item.quantity < q_req:
|
||||
q_req = stock_item.quantity
|
||||
|
||||
# Allocate parts to this build
|
||||
build_item = BuildItem(
|
||||
build=self,
|
||||
stock_item=stock_item,
|
||||
quantity=q_req)
|
||||
|
||||
build_item.save()
|
||||
|
||||
def __str__(self):
|
||||
return "Build {q} x {part}".format(q=self.quantity, part=str(self.part))
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('build-detail', kwargs={'pk': self.id})
|
||||
|
||||
@ -23,46 +85,101 @@ class Build(models.Model):
|
||||
related_name='builds',
|
||||
limit_choices_to={'buildable': True},
|
||||
)
|
||||
""" A reference to the part being built - only parts marked as 'buildable' may be selected """
|
||||
|
||||
#: Brief title describing the build
|
||||
title = models.CharField(max_length=100, help_text='Brief description of the build')
|
||||
|
||||
#: Number of output parts to build
|
||||
quantity = models.PositiveIntegerField(default=1,
|
||||
validators=[MinValueValidator(1)],
|
||||
help_text='Number of parts to build')
|
||||
|
||||
quantity = models.PositiveIntegerField(
|
||||
default=1,
|
||||
validators=[MinValueValidator(1)],
|
||||
help_text='Number of parts to build'
|
||||
)
|
||||
|
||||
# Build status codes
|
||||
PENDING = 10 # Build is pending / active
|
||||
HOLDING = 20 # Build is currently being held
|
||||
CANCELLED = 30 # Build was cancelled
|
||||
COMPLETE = 40 # Build is complete
|
||||
|
||||
#: Build status codes
|
||||
BUILD_STATUS_CODES = {PENDING: _("Pending"),
|
||||
HOLDING: _("Holding"),
|
||||
CANCELLED: _("Cancelled"),
|
||||
COMPLETE: _("Complete"),
|
||||
}
|
||||
|
||||
#: Status of the build (ref BUILD_STATUS_CODES)
|
||||
status = models.PositiveIntegerField(default=PENDING,
|
||||
choices=BUILD_STATUS_CODES.items(),
|
||||
validators=[MinValueValidator(0)])
|
||||
|
||||
#: Batch number for the build (optional)
|
||||
|
||||
batch = models.CharField(max_length=100, blank=True, null=True,
|
||||
help_text='Batch code for this build output')
|
||||
|
||||
#: Date the build model was 'created'
|
||||
|
||||
creation_date = models.DateField(auto_now=True, editable=False)
|
||||
|
||||
#: Date the build was 'completed' (and parts removed from stock)
|
||||
|
||||
completion_date = models.DateField(null=True, blank=True)
|
||||
|
||||
URL = models.URLField(blank=True, help_text='Link to external URL')
|
||||
|
||||
#: Notes attached to each build output
|
||||
notes = models.TextField(blank=True)
|
||||
""" Notes attached to each build output """
|
||||
|
||||
@transaction.atomic
|
||||
def cancelBuild(self):
|
||||
""" Mark the Build as CANCELLED
|
||||
|
||||
- Delete any pending BuildItem objects (but do not remove items from stock)
|
||||
- Set build status to CANCELLED
|
||||
- Save the Build object
|
||||
"""
|
||||
|
||||
for item in self.allocated_stock.all():
|
||||
item.delete()
|
||||
|
||||
self.status = self.CANCELLED
|
||||
self.save()
|
||||
|
||||
@transaction.atomic
|
||||
def completeBuild(self, location, user):
|
||||
""" Mark the Build as COMPLETE
|
||||
|
||||
- Takes allocated items from stock
|
||||
- Delete pending BuildItem objects
|
||||
"""
|
||||
|
||||
for item in self.allocated_stock.all():
|
||||
|
||||
# Subtract stock from the item
|
||||
item.stock_item.take_stock(
|
||||
item.quantity,
|
||||
user,
|
||||
'Removed {n} items to build {m} x {part}'.format(
|
||||
n=item.quantity,
|
||||
m=self.quantity,
|
||||
part=self.part.name
|
||||
)
|
||||
)
|
||||
|
||||
# Delete the item
|
||||
item.delete()
|
||||
|
||||
# Mark the date of completion
|
||||
self.completion_date = datetime.now().date()
|
||||
|
||||
# Add stock of the newly created item
|
||||
item = StockItem.objects.create(
|
||||
part=self.part,
|
||||
location=location,
|
||||
quantity=self.quantity,
|
||||
batch=str(self.batch) if self.batch else '',
|
||||
notes='Built {q} on {now}'.format(
|
||||
q=self.quantity,
|
||||
now=str(datetime.now().date())
|
||||
)
|
||||
)
|
||||
|
||||
item.save()
|
||||
|
||||
# Finally, mark the build as complete
|
||||
self.status = self.COMPLETE
|
||||
self.save()
|
||||
|
||||
@property
|
||||
def required_parts(self):
|
||||
@ -99,10 +216,77 @@ class Build(models.Model):
|
||||
|
||||
return self.status in [
|
||||
self.PENDING,
|
||||
self.HOLDING
|
||||
]
|
||||
|
||||
@property
|
||||
def is_complete(self):
|
||||
""" Returns True if the build status is COMPLETE """
|
||||
return self.status == self.COMPLETE
|
||||
|
||||
|
||||
class BuildItem(models.Model):
|
||||
""" A BuildItem links multiple StockItem objects to a Build.
|
||||
These are used to allocate part stock to a build.
|
||||
Once the Build is completed, the parts are removed from stock and the
|
||||
BuildItemAllocation objects are removed.
|
||||
|
||||
Attributes:
|
||||
build: Link to a Build object
|
||||
stock: Link to a StockItem object
|
||||
quantity: Number of units allocated
|
||||
"""
|
||||
|
||||
def get_absolute_url(self):
|
||||
# TODO - Fix!
|
||||
return '/build/item/{pk}/'.format(pk=self.id)
|
||||
# return reverse('build-detail', kwargs={'pk': self.id})
|
||||
|
||||
class Meta:
|
||||
unique_together = [
|
||||
('build', 'stock_item'),
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
""" Check validity of the BuildItem model.
|
||||
The following checks are performed:
|
||||
|
||||
- StockItem.part must be in the BOM of the Part object referenced by Build
|
||||
- Allocation quantity cannot exceed available quantity
|
||||
"""
|
||||
|
||||
super(BuildItem, self).clean()
|
||||
|
||||
errors = {}
|
||||
|
||||
if self.stock_item is not None and self.stock_item.part is not None:
|
||||
if self.stock_item.part not in self.build.part.required_parts():
|
||||
errors['stock_item'] = [_("Selected stock item not found in BOM for part '{p}'".format(p=self.build.part.name))]
|
||||
|
||||
if self.stock_item is not None and self.quantity > self.stock_item.quantity:
|
||||
errors['quantity'] = [_("Allocated quantity ({n}) must not exceed available quantity ({q})".format(
|
||||
n=self.quantity,
|
||||
q=self.stock_item.quantity
|
||||
))]
|
||||
|
||||
if len(errors) > 0:
|
||||
raise ValidationError(errors)
|
||||
|
||||
build = models.ForeignKey(
|
||||
Build,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='allocated_stock',
|
||||
help_text='Build to allocate parts'
|
||||
)
|
||||
|
||||
stock_item = models.ForeignKey(
|
||||
'stock.StockItem',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='allocations',
|
||||
help_text='Stock Item to allocate to build',
|
||||
)
|
||||
|
||||
quantity = models.PositiveIntegerField(
|
||||
default=1,
|
||||
validators=[MinValueValidator(1)],
|
||||
help_text='Stock quantity to allocate to build'
|
||||
)
|
||||
|
@ -6,11 +6,13 @@ JSON serializers for Build API
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework import serializers
|
||||
from InvenTree.serializers import InvenTreeModelSerializer
|
||||
from stock.serializers import StockItemSerializerBrief
|
||||
|
||||
from .models import Build
|
||||
from .models import Build, BuildItem
|
||||
|
||||
|
||||
class BuildSerializer(serializers.ModelSerializer):
|
||||
class BuildSerializer(InvenTreeModelSerializer):
|
||||
""" Serializes a Build object """
|
||||
|
||||
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
||||
@ -29,3 +31,30 @@ class BuildSerializer(serializers.ModelSerializer):
|
||||
'status',
|
||||
'status_text',
|
||||
'notes']
|
||||
|
||||
read_only_fields = [
|
||||
'status',
|
||||
'creation_date',
|
||||
'completion_data',
|
||||
'status_text',
|
||||
]
|
||||
|
||||
|
||||
class BuildItemSerializer(InvenTreeModelSerializer):
|
||||
""" Serializes a BuildItem object """
|
||||
|
||||
part = serializers.IntegerField(source='stock_item.part.pk', read_only=True)
|
||||
part_name = serializers.CharField(source='stock_item.part.name', read_only=True)
|
||||
stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = BuildItem
|
||||
fields = [
|
||||
'pk',
|
||||
'build',
|
||||
'part',
|
||||
'part_name',
|
||||
'stock_item',
|
||||
'stock_item_detail',
|
||||
'quantity'
|
||||
]
|
||||
|
@ -1,47 +1,59 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h3>Allocate Parts for Build</h3>
|
||||
|
||||
<h4><a href="{% url 'build-detail' build.id %}">{{ build.title }}</a></h4>
|
||||
|
||||
{{ build.quantity }} x {{ build.part.name }}
|
||||
<hr>
|
||||
|
||||
{% for bom_item in bom_items.all %}
|
||||
{% include "build/allocation_item.html" with item=bom_item build=build %}
|
||||
{% endfor %}
|
||||
|
||||
<table class='table table-striped' id='build-table'>
|
||||
</table>
|
||||
|
||||
<div>
|
||||
<button class='btn btn-warning' type='button' id='complete-build'>Complete Build</button>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js_load %}
|
||||
{{ block.super }}
|
||||
<script src="{% static 'script/inventree/api.js' %}"></script>
|
||||
<script src="{% static 'script/inventree/part.js' %}"></script>
|
||||
<script src="{% static 'script/inventree/build.js' %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
$('#build-table').bootstrapTable({
|
||||
sortable: true,
|
||||
columns: [
|
||||
{% for bom_item in bom_items.all %}
|
||||
|
||||
loadAllocationTable(
|
||||
$("#allocate-table-id-{{ bom_item.sub_part.id }}"),
|
||||
{{ bom_item.sub_part.id }},
|
||||
"{{ bom_item.sub_part.name }}",
|
||||
"{% url 'api-build-item-list' %}?build={{ build.id }}&part={{ bom_item.sub_part.id }}",
|
||||
{% multiply build.quantity bom_item.quantity %},
|
||||
$("#new-item-{{ bom_item.sub_part.id }}")
|
||||
);
|
||||
|
||||
{% endfor %}
|
||||
|
||||
$("#complete-build").on('click', function() {
|
||||
launchModalForm(
|
||||
"{% url 'build-complete' build.id %}",
|
||||
{
|
||||
field: 'sub_part.name',
|
||||
title: 'Part',
|
||||
},
|
||||
{
|
||||
title: 'Source',
|
||||
},
|
||||
{
|
||||
field: 'quantity',
|
||||
title: 'Quantity',
|
||||
reload: true,
|
||||
submit_text: "Complete Build",
|
||||
}
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
getBomList({part: {{ build.part.id }}}).then(function(response) {
|
||||
$("#build-table").bootstrapTable('load', response);
|
||||
});
|
||||
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
38
InvenTree/build/templates/build/allocation_item.html
Normal file
38
InvenTree/build/templates/build/allocation_item.html
Normal file
@ -0,0 +1,38 @@
|
||||
{% load inventree_extras %}
|
||||
|
||||
<div class='panel-group'>
|
||||
<div class='panel pane-default'>
|
||||
<div class='panel panel-heading'>
|
||||
<div class='row'>
|
||||
<div class='col-sm-6'>
|
||||
<div class='panel-title'>
|
||||
<a data-toggle='collapse' href='#collapse-item-{{ item.id }}'>{{ item.sub_part.name }}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class='col-sm-1' align='right'>
|
||||
Required:
|
||||
</div>
|
||||
<div class='col-sm-1'>
|
||||
<b>{% multiply build.quantity item.quantity %}</b>
|
||||
</div>
|
||||
<div class='col-sm-1' align='right'>
|
||||
Allocated:
|
||||
</div>
|
||||
<div class='col-sm-1' id='allocation-panel-{{ item.sub_part.id }}'>
|
||||
<b><span id='allocation-total-{{ item.sub_part.id }}'>0</span></b>
|
||||
</div>
|
||||
<div class='col-sm-2'>
|
||||
<div class='btn-group' style='float: right;'>
|
||||
<button class='btn btn-success btn-sm' id='new-item-{{ item.sub_part.id }}' url="{% url 'build-item-create' %}?part={{ item.sub_part.id }}&build={{ build.id }}">Allocate Parts</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id='collapse-item-{{ item.id }}' class='panel-collapse collapse'>
|
||||
<div class='panel-body'>
|
||||
<table class='table table-striped table-condensed' id='allocate-table-id-{{ item.sub_part.id }}'>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
24
InvenTree/build/templates/build/complete.html
Normal file
24
InvenTree/build/templates/build/complete.html
Normal file
@ -0,0 +1,24 @@
|
||||
{% extends "modal_form.html" %}
|
||||
|
||||
{% block pre_form_content %}
|
||||
<b>Build: {{ build.title }}</b> - {{ build.quantity }} x {{ build.part.name }}
|
||||
<br>
|
||||
Are you sure you want to mark this build as complete?
|
||||
<hr>
|
||||
{% if taking %}
|
||||
The following items will be removed from stock:
|
||||
<ul>
|
||||
{% for item in taking %}
|
||||
<li>{{ item.quantity }} x {{ item.stock_item.part.name }} from {{ item.stock_item.location }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
No parts have been allocated to this build.
|
||||
{% endif %}
|
||||
<hr>
|
||||
The following items will be created:
|
||||
<ul>
|
||||
<li>{{ build.quantity }} x {{ build.part.name }}</li>
|
||||
</ul>
|
||||
|
||||
{% endblock %}
|
3
InvenTree/build/templates/build/delete_build_item.html
Normal file
3
InvenTree/build/templates/build/delete_build_item.html
Normal file
@ -0,0 +1,3 @@
|
||||
Are you sure you want to unallocate these parts?
|
||||
<br>
|
||||
This will remove {{ item.quantity }} parts from build '{{ item.build.title }}'.
|
@ -38,14 +38,19 @@
|
||||
<tr>
|
||||
<td>Quantity</td><td>{{ build.quantity }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Status</td><td>{% include "build_status.html" with build=build %}</td>
|
||||
</tr>
|
||||
{% if build.batch %}
|
||||
<tr>
|
||||
<td>Batch</td><td>{{ build.batch }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if build.URL %}
|
||||
<tr>
|
||||
<td>Status</td><td>{% include "build_status.html" with build=build %}</td>
|
||||
<td>URL</td><td><a href="{{ build.URL }}">{{ build.URL }}</a></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td>Created</td><td>{{ build.creation_date }}</td>
|
||||
</tr>
|
||||
@ -73,6 +78,7 @@
|
||||
{% endif %}
|
||||
</table>
|
||||
|
||||
{% if build.is_active %}
|
||||
<h3>Required Parts</h3>
|
||||
<table class='table table-striped' id='build-list' data-sorting='true'>
|
||||
<thead>
|
||||
@ -93,6 +99,8 @@
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% include 'modals.html' %}
|
||||
|
||||
{% endblock %}
|
||||
@ -110,7 +118,7 @@
|
||||
launchModalForm("{% url 'build-cancel' build.id %}",
|
||||
{
|
||||
reload: true,
|
||||
submit_text: "Cancel",
|
||||
submit_text: "Cancel Build",
|
||||
});
|
||||
});
|
||||
{% endblock %}
|
||||
|
0
InvenTree/build/templatetags/__init__.py
Normal file
0
InvenTree/build/templatetags/__init__.py
Normal file
12
InvenTree/build/templatetags/inventree_extras.py
Normal file
12
InvenTree/build/templatetags/inventree_extras.py
Normal file
@ -0,0 +1,12 @@
|
||||
""" This module provides template tags for extra functionality
|
||||
over and above the built-in Django tags.
|
||||
"""
|
||||
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def multiply(x, y, *args, **kwargs):
|
||||
return x * y
|
@ -6,16 +6,29 @@ from django.conf.urls import url, include
|
||||
|
||||
from . import views
|
||||
|
||||
build_item_detail_urls = [
|
||||
url('^edit/?', views.BuildItemEdit.as_view(), name='build-item-edit'),
|
||||
url('^delete/?', views.BuildItemDelete.as_view(), name='build-item-delete'),
|
||||
]
|
||||
|
||||
build_item_urls = [
|
||||
url(r'^(?P<pk>\d+)/', include(build_item_detail_urls)),
|
||||
url('^new/', views.BuildItemCreate.as_view(), name='build-item-create'),
|
||||
]
|
||||
|
||||
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'^complete/?', views.BuildComplete.as_view(), name='build-complete'),
|
||||
|
||||
url(r'^.*$', views.BuildDetail.as_view(), name='build-detail'),
|
||||
]
|
||||
|
||||
build_urls = [
|
||||
url(r'new/?', views.BuildCreate.as_view(), name='build-create'),
|
||||
url(r'item/', include(build_item_urls)),
|
||||
|
||||
url(r'new/', views.BuildCreate.as_view(), name='build-create'),
|
||||
|
||||
url(r'^(?P<pk>\d+)/', include(build_detail_urls)),
|
||||
|
||||
|
@ -8,12 +8,14 @@ from __future__ import unicode_literals
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from django.views.generic import DetailView, ListView
|
||||
from django.forms import HiddenInput
|
||||
|
||||
from part.models import Part
|
||||
from .models import Build
|
||||
from .forms import EditBuildForm
|
||||
from .models import Build, BuildItem
|
||||
from .forms import EditBuildForm, EditBuildItemForm, CompleteBuildForm
|
||||
from stock.models import StockLocation, StockItem
|
||||
|
||||
from InvenTree.views import AjaxView, AjaxUpdateView, AjaxCreateView
|
||||
from InvenTree.views import AjaxView, AjaxUpdateView, AjaxCreateView, AjaxDeleteView
|
||||
|
||||
|
||||
class BuildIndex(ListView):
|
||||
@ -46,7 +48,7 @@ class BuildCancel(AjaxView):
|
||||
Provides a cancellation information dialog
|
||||
"""
|
||||
model = Build
|
||||
template_name = 'build/cancel.html'
|
||||
ajax_template_name = 'build/cancel.html'
|
||||
ajax_form_title = 'Cancel Build'
|
||||
context_object_name = 'build'
|
||||
fields = []
|
||||
@ -56,15 +58,109 @@ class BuildCancel(AjaxView):
|
||||
|
||||
build = get_object_or_404(Build, pk=self.kwargs['pk'])
|
||||
|
||||
build.status = Build.CANCELLED
|
||||
build.save()
|
||||
build.cancelBuild()
|
||||
|
||||
return self.renderJsonResponse(request, None)
|
||||
|
||||
def get_data(self):
|
||||
""" Provide JSON context data. """
|
||||
return {
|
||||
'info': 'Build was cancelled'
|
||||
'danger': 'Build was cancelled'
|
||||
}
|
||||
|
||||
|
||||
class BuildComplete(AjaxUpdateView):
|
||||
""" View to mark a build as Complete.
|
||||
|
||||
- Notifies the user of which parts will be removed from stock.
|
||||
- Removes allocated items from stock
|
||||
- Deletes pending BuildItem objects
|
||||
"""
|
||||
|
||||
model = Build
|
||||
form_class = CompleteBuildForm
|
||||
context_object_name = "build"
|
||||
ajax_form_title = "Complete Build"
|
||||
ajax_template_name = "build/complete.html"
|
||||
|
||||
def get_initial(self):
|
||||
""" Get initial form data for the CompleteBuild form
|
||||
|
||||
- If the part being built has a default location, pre-select that location
|
||||
"""
|
||||
|
||||
initials = super(BuildComplete, self).get_initial().copy()
|
||||
|
||||
build = self.get_object()
|
||||
if build.part.default_location is not None:
|
||||
try:
|
||||
location = StockLocation.objects.get(pk=build.part.default_location.id)
|
||||
initials['location'] = location
|
||||
except StockLocation.DoesNotExist:
|
||||
pass
|
||||
|
||||
return initials
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
""" Get context data for passing to the rendered form
|
||||
|
||||
- Build information is required
|
||||
"""
|
||||
|
||||
build = self.get_object()
|
||||
|
||||
# Build object
|
||||
context = super(BuildComplete, self).get_context_data(**kwargs).copy()
|
||||
context['build'] = build
|
||||
|
||||
# Items to be removed from stock
|
||||
taking = BuildItem.objects.filter(build=build.id)
|
||||
context['taking'] = taking
|
||||
|
||||
return context
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
""" Handle POST request. Mark the build as COMPLETE
|
||||
|
||||
- If the form validation passes, the Build objects completeBuild() method is called
|
||||
- Otherwise, the form is passed back to the client
|
||||
"""
|
||||
|
||||
build = self.get_object()
|
||||
|
||||
form = self.get_form()
|
||||
|
||||
confirm = request.POST.get('confirm', False)
|
||||
|
||||
loc_id = request.POST.get('location', None)
|
||||
|
||||
valid = False
|
||||
|
||||
if confirm is False:
|
||||
form.errors['confirm'] = [
|
||||
'Confirm completion of build',
|
||||
]
|
||||
else:
|
||||
try:
|
||||
location = StockLocation.objects.get(id=loc_id)
|
||||
valid = True
|
||||
except StockLocation.DoesNotExist:
|
||||
print('id:', loc_id)
|
||||
form.errors['location'] = ['Invalid location selected']
|
||||
|
||||
if valid:
|
||||
build.completeBuild(location, request.user)
|
||||
|
||||
data = {
|
||||
'form_valid': valid,
|
||||
}
|
||||
|
||||
return self.renderJsonResponse(request, form, data)
|
||||
|
||||
def get_data(self):
|
||||
""" Provide feedback data back to the form """
|
||||
return {
|
||||
'info': 'Build marked as COMPLETE'
|
||||
}
|
||||
|
||||
|
||||
@ -81,6 +177,20 @@ class BuildAllocate(DetailView):
|
||||
context_object_name = 'build'
|
||||
template_name = 'build/allocate.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
""" Provide extra context information for the Build allocation page """
|
||||
|
||||
context = super(DetailView, self).get_context_data(**kwargs)
|
||||
|
||||
build = self.get_object()
|
||||
part = build.part
|
||||
bom_items = part.bom_items
|
||||
|
||||
context['part'] = part
|
||||
context['bom_items'] = bom_items
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class BuildCreate(AjaxCreateView):
|
||||
""" View to create a new Build object """
|
||||
@ -127,3 +237,116 @@ class BuildUpdate(AjaxUpdateView):
|
||||
return {
|
||||
'info': 'Edited build',
|
||||
}
|
||||
|
||||
|
||||
class BuildItemDelete(AjaxDeleteView):
|
||||
""" View to 'unallocate' a BuildItem.
|
||||
Really we are deleting the BuildItem object from the database.
|
||||
"""
|
||||
|
||||
model = BuildItem
|
||||
ajax_template_name = 'build/delete_build_item.html'
|
||||
ajax_form_title = 'Unallocate Stock'
|
||||
context_object_name = 'item'
|
||||
|
||||
def get_data(self):
|
||||
return {
|
||||
'danger': 'Removed parts from build allocation'
|
||||
}
|
||||
|
||||
|
||||
class BuildItemCreate(AjaxCreateView):
|
||||
""" View for allocating a new part to a build """
|
||||
|
||||
model = BuildItem
|
||||
form_class = EditBuildItemForm
|
||||
ajax_template_name = 'modal_form.html'
|
||||
ajax_form_title = 'Allocate new Part'
|
||||
|
||||
def get_form(self):
|
||||
""" Create Form for making / editing new Part object """
|
||||
|
||||
form = super(AjaxCreateView, self).get_form()
|
||||
|
||||
# If the Build object is specified, hide the input field.
|
||||
# We do not want the users to be able to move a BuildItem to a different build
|
||||
build_id = form['build'].value()
|
||||
|
||||
if build_id is not None:
|
||||
form.fields['build'].widget = HiddenInput()
|
||||
|
||||
# If the sub_part is supplied, limit to matching stock items
|
||||
part_id = self.get_param('part')
|
||||
|
||||
if part_id:
|
||||
try:
|
||||
Part.objects.get(pk=part_id)
|
||||
|
||||
query = form.fields['stock_item'].queryset
|
||||
|
||||
# Only allow StockItem objects which match the current part
|
||||
query = query.filter(part=part_id)
|
||||
|
||||
if build_id is not None:
|
||||
# Exclude StockItem objects which are already allocated to this build and part
|
||||
query = query.exclude(id__in=[item.stock_item.id for item in BuildItem.objects.filter(build=build_id, stock_item__part=part_id)])
|
||||
|
||||
form.fields['stock_item'].queryset = query
|
||||
except Part.DoesNotExist:
|
||||
pass
|
||||
|
||||
return form
|
||||
|
||||
def get_initial(self):
|
||||
""" Provide initial data for BomItem. Look for the folllowing in the GET data:
|
||||
|
||||
- build: pk of the Build object
|
||||
"""
|
||||
|
||||
initials = super(AjaxCreateView, self).get_initial().copy()
|
||||
|
||||
build_id = self.get_param('build')
|
||||
|
||||
if build_id:
|
||||
try:
|
||||
initials['build'] = Build.objects.get(pk=build_id)
|
||||
except Build.DoesNotExist:
|
||||
pass
|
||||
|
||||
return initials
|
||||
|
||||
|
||||
class BuildItemEdit(AjaxUpdateView):
|
||||
""" View to edit a BuildItem object """
|
||||
|
||||
model = BuildItem
|
||||
ajax_template_name = 'modal_form.html'
|
||||
form_class = EditBuildItemForm
|
||||
ajax_form_title = 'Edit Stock Allocation'
|
||||
|
||||
def get_data(self):
|
||||
return {
|
||||
'info': 'Updated Build Item',
|
||||
}
|
||||
|
||||
def get_form(self):
|
||||
""" Create form for editing a BuildItem.
|
||||
|
||||
- Limit the StockItem options to items that match the part
|
||||
"""
|
||||
|
||||
build_item = self.get_object()
|
||||
|
||||
form = super(BuildItemEdit, self).get_form()
|
||||
|
||||
query = StockItem.objects.all()
|
||||
|
||||
if build_item.stock_item:
|
||||
part_id = build_item.stock_item.part.id
|
||||
query = query.filter(part=part_id)
|
||||
|
||||
form.fields['stock_item'].queryset = query
|
||||
|
||||
form.fields['build'].widget = HiddenInput()
|
||||
|
||||
return form
|
||||
|
@ -19,4 +19,4 @@
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
</ul>
|
||||
|
@ -19,7 +19,7 @@ class PartCategoryAdmin(ImportExportModelAdmin):
|
||||
|
||||
class PartAttachmentAdmin(admin.ModelAdmin):
|
||||
|
||||
list_display = ('part', 'attachment')
|
||||
list_display = ('part', 'attachment', 'comment')
|
||||
|
||||
|
||||
class BomItemAdmin(ImportExportModelAdmin):
|
||||
|
18
InvenTree/part/migrations/0013_auto_20190429_2229.py
Normal file
18
InvenTree/part/migrations/0013_auto_20190429_2229.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2 on 2019-04-29 12:29
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('part', '0012_part_active'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='part',
|
||||
name='URL',
|
||||
field=models.URLField(blank=True, help_text='Link to external URL'),
|
||||
),
|
||||
]
|
18
InvenTree/part/migrations/0014_partattachment_comment.py
Normal file
18
InvenTree/part/migrations/0014_partattachment_comment.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2 on 2019-04-30 23:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('part', '0013_auto_20190429_2229'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='partattachment',
|
||||
name='comment',
|
||||
field=models.CharField(blank=True, help_text='Attachment description', max_length=100),
|
||||
),
|
||||
]
|
@ -125,7 +125,7 @@ class Part(models.Model):
|
||||
IPN = models.CharField(max_length=100, blank=True, help_text='Internal Part Number')
|
||||
|
||||
# Provide a URL for an external link
|
||||
URL = models.URLField(blank=True, help_text='Link to extenal URL')
|
||||
URL = models.URLField(blank=True, help_text='Link to external URL')
|
||||
|
||||
# Part category - all parts must be assigned to a category
|
||||
category = models.ForeignKey(PartCategory, related_name='parts',
|
||||
@ -307,6 +307,12 @@ class Part(models.Model):
|
||||
def used_in_count(self):
|
||||
return self.used_in.count()
|
||||
|
||||
def required_parts(self):
|
||||
parts = []
|
||||
for bom in self.bom_items.all():
|
||||
parts.append(bom.sub_part)
|
||||
return parts
|
||||
|
||||
@property
|
||||
def supplier_count(self):
|
||||
# Return the number of supplier parts available for this part
|
||||
@ -360,6 +366,8 @@ class PartAttachment(models.Model):
|
||||
|
||||
attachment = models.FileField(upload_to=attach_file, null=True, blank=True)
|
||||
|
||||
comment = models.CharField(max_length=100, blank=True, help_text="Attachment description")
|
||||
|
||||
@property
|
||||
def basename(self):
|
||||
return os.path.basename(self.attachment.name)
|
||||
@ -399,8 +407,11 @@ class BomItem(models.Model):
|
||||
- A part cannot refer to a part which refers to it
|
||||
"""
|
||||
|
||||
if self.part is None or self.sub_part is None:
|
||||
# Field validation will catch these None values
|
||||
pass
|
||||
# A part cannot refer to itself in its BOM
|
||||
if self.part == self.sub_part:
|
||||
elif self.part == self.sub_part:
|
||||
raise ValidationError({'sub_part': _('Part cannot be added to its own Bill of Materials')})
|
||||
|
||||
# Test for simple recursion
|
||||
|
@ -60,7 +60,14 @@
|
||||
});
|
||||
|
||||
$("#bom-item-new").click(function () {
|
||||
launchModalForm("{% url 'bom-item-create' %}?parent={{ part.id }}", {});
|
||||
launchModalForm(
|
||||
"{% url 'bom-item-create' %}?parent={{ part.id }}",
|
||||
{
|
||||
success: function() {
|
||||
$("#bom-table").bootstrapTable('refresh');
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
{% else %}
|
||||
|
@ -73,7 +73,6 @@ class PartCreate(AjaxCreateView):
|
||||
def get_category_id(self):
|
||||
return self.request.GET.get('category', None)
|
||||
|
||||
# If a category is provided in the URL, pass that to the page context
|
||||
def get_context_data(self, **kwargs):
|
||||
""" Provide extra context information for the form to display:
|
||||
|
||||
@ -99,7 +98,7 @@ class PartCreate(AjaxCreateView):
|
||||
form = super(AjaxCreateView, self).get_form()
|
||||
|
||||
# Hide the default_supplier field (there are no matching supplier parts yet!)
|
||||
form.fields['default_supplier'] = HiddenInput()
|
||||
form.fields['default_supplier'].widget = HiddenInput()
|
||||
|
||||
return form
|
||||
|
||||
@ -385,6 +384,35 @@ class BomItemCreate(AjaxCreateView):
|
||||
ajax_template_name = 'modal_form.html'
|
||||
ajax_form_title = 'Create BOM item'
|
||||
|
||||
def get_form(self):
|
||||
""" Override get_form() method to reduce Part selection options.
|
||||
|
||||
- Do not allow part to be added to its own BOM
|
||||
- Remove any Part items that are already in the BOM
|
||||
"""
|
||||
|
||||
form = super(AjaxCreateView, self).get_form()
|
||||
|
||||
part_id = form['part'].value()
|
||||
|
||||
try:
|
||||
part = Part.objects.get(id=part_id)
|
||||
|
||||
# Don't allow selection of sub_part objects which are already added to the Bom!
|
||||
query = form.fields['sub_part'].queryset
|
||||
|
||||
# Don't allow a part to be added to its own BOM
|
||||
query = query.exclude(id=part.id)
|
||||
|
||||
# Eliminate any options that are already in the BOM!
|
||||
query = query.exclude(id__in=[item.id for item in part.required_parts()])
|
||||
|
||||
form.fields['sub_part'].queryset = query
|
||||
except Part.DoesNotExist:
|
||||
pass
|
||||
|
||||
return form
|
||||
|
||||
def get_initial(self):
|
||||
""" Provide initial data for the BomItem:
|
||||
|
||||
|
@ -122,6 +122,10 @@
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.panel-group {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.float-right {
|
||||
float: right;
|
||||
}
|
||||
@ -135,4 +139,16 @@
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
.part-allocation-pass {
|
||||
background: #dbf0db;
|
||||
}
|
||||
|
||||
.part-allocation-underallocated {
|
||||
background: #f0dbdb;
|
||||
}
|
||||
|
||||
.part-allocation-overallocated {
|
||||
background: #ccf5ff;
|
||||
}
|
||||
|
@ -114,6 +114,41 @@ function loadBomTable(table, options) {
|
||||
}
|
||||
);
|
||||
|
||||
if (options.editable) {
|
||||
cols.push({
|
||||
formatter: function(value, row, index, field) {
|
||||
var bEdit = "<button class='btn btn-success bom-edit-button btn-sm' type='button' url='/part/bom/" + row.pk + "/edit'>Edit</button>";
|
||||
var bDelt = "<button class='btn btn-danger bom-delete-button btn-sm' type='button' url='/part/bom/" + row.pk + "/delete'>Delete</button>";
|
||||
|
||||
return "<div class='btn-group'>" + bEdit + bDelt + "</div>";
|
||||
}
|
||||
});
|
||||
}
|
||||
else {
|
||||
cols.push(
|
||||
{
|
||||
field: 'sub_part_detail.available_stock',
|
||||
title: 'Available',
|
||||
searchable: false,
|
||||
sortable: true,
|
||||
formatter: function(value, row, index, field) {
|
||||
var text = "";
|
||||
|
||||
if (row.quantity < row.sub_part_detail.available_stock)
|
||||
{
|
||||
text = "<span class='label label-success'>" + value + "</span>";
|
||||
}
|
||||
else
|
||||
{
|
||||
text = "<span class='label label-warning'>" + value + "</span>";
|
||||
}
|
||||
|
||||
return renderLink(text, row.sub_part.url + "stock/");
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Part notes
|
||||
cols.push(
|
||||
{
|
||||
@ -124,43 +159,8 @@ function loadBomTable(table, options) {
|
||||
}
|
||||
);
|
||||
|
||||
if (options.editable) {
|
||||
cols.push({
|
||||
formatter: function(value, row, index, field) {
|
||||
var bEdit = "<button class='btn btn-success bom-edit-button btn-sm' type='button' url='/part/bom/" + row.pk + "/edit'>Edit</button>";
|
||||
var bDelt = "<button class='btn btn-danger bom-delete-button btn-sm' type='button' url='/part/bom/" + row.pk + "/delete'>Delete</button>";
|
||||
|
||||
return "<div class='btn-group'>" + bEdit + bDelt + "</div>";
|
||||
}
|
||||
});
|
||||
}
|
||||
else {
|
||||
cols.push(
|
||||
{
|
||||
field: 'sub_part_detail.available_stock',
|
||||
title: 'Available',
|
||||
searchable: false,
|
||||
sortable: true,
|
||||
formatter: function(value, row, index, field) {
|
||||
var text = "";
|
||||
|
||||
if (row.quantity < row.sub_part_detail.available_stock)
|
||||
{
|
||||
text = "<span class='label label-success'>" + value + "</span>";
|
||||
}
|
||||
else
|
||||
{
|
||||
text = "<span class='label label-warning'>" + value + "</span>";
|
||||
}
|
||||
|
||||
return renderLink(text, row.sub_part.url + "stock/");
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Configure the table (bootstrap-table)
|
||||
|
||||
|
||||
table.bootstrapTable({
|
||||
sortable: true,
|
||||
search: true,
|
||||
@ -168,11 +168,11 @@ function loadBomTable(table, options) {
|
||||
queryParams: function(p) {
|
||||
return {
|
||||
part: options.parent_id,
|
||||
}
|
||||
},
|
||||
columns: cols,
|
||||
url: options.bom_url
|
||||
});
|
||||
}
|
||||
},
|
||||
columns: cols,
|
||||
url: options.bom_url
|
||||
});
|
||||
|
||||
// In editing mode, attached editables to the appropriate table elements
|
||||
if (options.editable) {
|
||||
|
97
InvenTree/static/script/inventree/build.js
Normal file
97
InvenTree/static/script/inventree/build.js
Normal file
@ -0,0 +1,97 @@
|
||||
function updateAllocationTotal(id, count, required) {
|
||||
|
||||
|
||||
$('#allocation-total-'+id).html(count);
|
||||
|
||||
var el = $("#allocation-panel-" + id);
|
||||
el.removeClass('part-allocation-pass part-allocation-underallocated part-allocation-overallocated');
|
||||
|
||||
if (count < required) {
|
||||
el.addClass('part-allocation-underallocated');
|
||||
} else if (count > required) {
|
||||
el.addClass('part-allocation-overallocated');
|
||||
} else {
|
||||
el.addClass('part-allocation-pass');
|
||||
}
|
||||
}
|
||||
|
||||
function loadAllocationTable(table, part_id, part, url, required, button) {
|
||||
|
||||
// Load the allocation table
|
||||
table.bootstrapTable({
|
||||
url: url,
|
||||
sortable: false,
|
||||
formatNoMatches: function() { return 'No parts allocated for ' + part; },
|
||||
columns: [
|
||||
{
|
||||
field: 'stock_item_detail',
|
||||
title: 'Stock Item',
|
||||
formatter: function(value, row, index, field) {
|
||||
return '' + value.quantity + ' x ' + value.part_name + ' @ ' + value.location_name;
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'stock_item_detail.quantity',
|
||||
title: 'Available',
|
||||
},
|
||||
{
|
||||
field: 'quantity',
|
||||
title: 'Allocated',
|
||||
formatter: function(value, row, index, field) {
|
||||
var html = value;
|
||||
|
||||
var bEdit = "<button class='btn btn-success item-edit-button btn-sm' type='button' url='/build/item/" + row.pk + "/edit/'>Edit</button>";
|
||||
var bDel = "<button class='btn btn-danger item-del-button btn-sm' type='button' url='/build/item/" + row.pk + "/delete/'>Delete</button>";
|
||||
|
||||
html += "<div class='btn-group' style='float: right;'>" + bEdit + bDel + "</div>";
|
||||
|
||||
return html;
|
||||
}
|
||||
}
|
||||
],
|
||||
});
|
||||
|
||||
// Callback for 'new-item' button
|
||||
button.click(function() {
|
||||
launchModalForm(button.attr('url'), {
|
||||
success: function() {
|
||||
table.bootstrapTable('refresh');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
table.on('load-success.bs.table', function(data) {
|
||||
// Extract table data
|
||||
var results = table.bootstrapTable('getData');
|
||||
|
||||
var count = 0;
|
||||
|
||||
for (var i = 0; i < results.length; i++) {
|
||||
count += results[i].quantity;
|
||||
}
|
||||
|
||||
updateAllocationTotal(part_id, count, required);
|
||||
});
|
||||
|
||||
// Button callbacks for editing and deleting the allocations
|
||||
table.on('click', '.item-edit-button', function() {
|
||||
var button = $(this);
|
||||
|
||||
launchModalForm(button.attr('url'), {
|
||||
success: function() {
|
||||
table.bootstrapTable('refresh');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
table.on('click', '.item-del-button', function() {
|
||||
var button = $(this);
|
||||
|
||||
launchDeleteForm(button.attr('url'), {
|
||||
success: function() {
|
||||
table.bootstrapTable('refresh');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
}
|
@ -15,5 +15,5 @@ function getPartList(filters={}, options={}) {
|
||||
}
|
||||
|
||||
function getBomList(filters={}, options={}) {
|
||||
return inventreeGet('/api/part/bom/', filters, options);
|
||||
return inventreeGet('/api/bom/', filters, options);
|
||||
}
|
@ -2,10 +2,12 @@ function editButton(url, text='Edit') {
|
||||
return "<button class='btn btn-success edit-button btn-sm' type='button' url='" + url + "'>" + text + "</button>";
|
||||
}
|
||||
|
||||
|
||||
function deleteButton(url, text='Delete') {
|
||||
return "<button class='btn btn-danger delete-button btn-sm' type='button' url='" + url + "'>" + text + "</button>";
|
||||
}
|
||||
|
||||
|
||||
function renderLink(text, url) {
|
||||
if (text === '' || url === '') {
|
||||
return text;
|
||||
@ -14,51 +16,6 @@ function renderLink(text, url) {
|
||||
return '<a href="' + url + '">' + text + '</a>';
|
||||
}
|
||||
|
||||
function renderEditable(text, options) {
|
||||
/* Wrap the text in an 'editable' link
|
||||
* (using bootstrap-editable library)
|
||||
*
|
||||
* Can pass the following parameters in 'options':
|
||||
* _type - parameter for data-type (default = 'text')
|
||||
* _pk - parameter for data-pk (required)
|
||||
* _title - title to show when editing
|
||||
* _empty - placeholder text to show when field is empty
|
||||
* _class - html class (default = 'editable-item')
|
||||
* _id - id
|
||||
* _value - Initial value of the editable (default = blank)
|
||||
*/
|
||||
|
||||
// Default values (if not supplied)
|
||||
var _type = options._type || 'text';
|
||||
var _class = options._class || 'editable-item';
|
||||
|
||||
var html = "<a href='#' class='" + _class + "'";
|
||||
|
||||
// Add id parameter if provided
|
||||
if (options._id) {
|
||||
html = html + " id='" + options._id + "'";
|
||||
}
|
||||
|
||||
html = html + " data-type='" + _type + "'";
|
||||
html = html + " data-pk='" + options._pk + "'";
|
||||
|
||||
if (options._title) {
|
||||
html = html + " data-title='" + options._title + "'";
|
||||
}
|
||||
|
||||
if (options._value) {
|
||||
html = html + " data-value='" + options._value + "'";
|
||||
}
|
||||
|
||||
if (options._empty) {
|
||||
html = html + " data-placeholder='" + options._empty + "'";
|
||||
html = html + " data-emptytext='" + options._empty + "'";
|
||||
}
|
||||
|
||||
html = html + ">" + text + "</a>";
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function enableButtons(elements, enabled) {
|
||||
for (let item of elements) {
|
||||
|
18
InvenTree/stock/migrations/0010_auto_20190501_2344.py
Normal file
18
InvenTree/stock/migrations/0010_auto_20190501_2344.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2 on 2019-05-01 13:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stock', '0009_auto_20190428_0841'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='stockitem',
|
||||
name='batch',
|
||||
field=models.CharField(blank=True, help_text='Batch code for this stock item', max_length=100, null=True),
|
||||
),
|
||||
]
|
@ -158,7 +158,7 @@ class StockItem(models.Model):
|
||||
URL = models.URLField(max_length=125, blank=True)
|
||||
|
||||
# Optional batch information
|
||||
batch = models.CharField(max_length=100, blank=True,
|
||||
batch = models.CharField(max_length=100, blank=True, null=True,
|
||||
help_text='Batch code for this stock item')
|
||||
|
||||
# If this part was produced by a build, point to that build here
|
||||
|
@ -29,12 +29,9 @@ class LocationBriefSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class StockItemSerializerBrief(serializers.ModelSerializer):
|
||||
"""
|
||||
Provide a brief serializer for StockItem
|
||||
"""
|
||||
|
||||
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
||||
""" Brief serializers for a StockItem """
|
||||
|
||||
location_name = serializers.CharField(source='location', read_only=True)
|
||||
part_name = serializers.CharField(source='part.name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
@ -42,8 +39,12 @@ class StockItemSerializerBrief(serializers.ModelSerializer):
|
||||
fields = [
|
||||
'pk',
|
||||
'uuid',
|
||||
'url',
|
||||
'part',
|
||||
'part_name',
|
||||
'supplier_part',
|
||||
'location',
|
||||
'location_name',
|
||||
'quantity',
|
||||
]
|
||||
|
||||
|
||||
|
@ -1,3 +1,6 @@
|
||||
{% block pre_form_content %}
|
||||
{% endblock %}
|
||||
|
||||
{% if form.non_field_errors %}
|
||||
<div class='alert alert-danger' role='alert' style='display: block;'>
|
||||
<b>Error Submitting Form:</b>
|
||||
@ -11,4 +14,7 @@
|
||||
|
||||
|
||||
{% crispy form %}
|
||||
</form>
|
||||
</form>
|
||||
|
||||
{% block post_form_content %}
|
||||
{% endblock %}
|
@ -32,7 +32,7 @@
|
||||
<div class='modal-form-content'>
|
||||
</div>
|
||||
<div class='modal-footer'>
|
||||
<button type='button' class='btn btn-default' data-dismiss='modal'>Close</button>
|
||||
<button type='button' class='btn btn-default' data-dismiss='modal'>Cancel</button>
|
||||
<button type='button' class='btn btn-danger' id='modal-form-delete'>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
Loading…
Reference in New Issue
Block a user