Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2019-05-02 00:30:17 +10:00
commit a2314074d3
38 changed files with 1106 additions and 175 deletions

View File

@ -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.

View File

@ -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)

View File

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

View File

@ -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',
]

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

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

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

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

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

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

View File

@ -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'
)

View File

@ -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'
]

View File

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

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

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

View 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 }}'.

View File

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

View File

View 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

View File

@ -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)),

View File

@ -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

View File

@ -19,4 +19,4 @@
</li>
{% endif %}
{% endif %}
</ul>
</ul>

View File

@ -19,7 +19,7 @@ class PartCategoryAdmin(ImportExportModelAdmin):
class PartAttachmentAdmin(admin.ModelAdmin):
list_display = ('part', 'attachment')
list_display = ('part', 'attachment', 'comment')
class BomItemAdmin(ImportExportModelAdmin):

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

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

View File

@ -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

View File

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

View File

@ -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:

View File

@ -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;
}

View File

@ -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) {

View 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');
}
});
});
}

View File

@ -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);
}

View File

@ -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) {

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

View File

@ -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

View File

@ -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',
]

View File

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

View File

@ -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>