diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py index 26303a6549..04d1978745 100644 --- a/InvenTree/InvenTree/views.py +++ b/InvenTree/InvenTree/views.py @@ -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. diff --git a/InvenTree/build/admin.py b/InvenTree/build/admin.py index 9662f6bf79..0ef5ccb75b 100644 --- a/InvenTree/build/admin.py +++ b/InvenTree/build/admin.py @@ -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) diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py index 393bd1f073..8e0a92917e 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -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'), ] diff --git a/InvenTree/build/forms.py b/InvenTree/build/forms.py index 6254f0745c..de56ca34f7 100644 --- a/InvenTree/build/forms.py +++ b/InvenTree/build/forms.py @@ -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', ] diff --git a/InvenTree/build/migrations/0003_builditemallocation.py b/InvenTree/build/migrations/0003_builditemallocation.py new file mode 100644 index 0000000000..add13c7ac1 --- /dev/null +++ b/InvenTree/build/migrations/0003_builditemallocation.py @@ -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')), + ], + ), + ] diff --git a/InvenTree/build/migrations/0004_build_url.py b/InvenTree/build/migrations/0004_build_url.py new file mode 100644 index 0000000000..187ac938d1 --- /dev/null +++ b/InvenTree/build/migrations/0004_build_url.py @@ -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'), + ), + ] diff --git a/InvenTree/build/migrations/0005_auto_20190429_2229.py b/InvenTree/build/migrations/0005_auto_20190429_2229.py new file mode 100644 index 0000000000..73ebca7e1b --- /dev/null +++ b/InvenTree/build/migrations/0005_auto_20190429_2229.py @@ -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', + ), + ] diff --git a/InvenTree/build/migrations/0006_auto_20190429_2233.py b/InvenTree/build/migrations/0006_auto_20190429_2233.py new file mode 100644 index 0000000000..5d03e13b75 --- /dev/null +++ b/InvenTree/build/migrations/0006_auto_20190429_2233.py @@ -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', + ), + ] diff --git a/InvenTree/build/migrations/0007_auto_20190429_2255.py b/InvenTree/build/migrations/0007_auto_20190429_2255.py new file mode 100644 index 0000000000..8a01606b84 --- /dev/null +++ b/InvenTree/build/migrations/0007_auto_20190429_2255.py @@ -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')}, + ), + ] diff --git a/InvenTree/build/migrations/0008_auto_20190501_2344.py b/InvenTree/build/migrations/0008_auto_20190501_2344.py new file mode 100644 index 0000000000..febdd2d1b1 --- /dev/null +++ b/InvenTree/build/migrations/0008_auto_20190501_2344.py @@ -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'), + ), + ] diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 880c1b08b1..79bedd5616 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -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' + ) diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index 955a8d59f5..947f3f6dc1 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -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' + ] diff --git a/InvenTree/build/templates/build/allocate.html b/InvenTree/build/templates/build/allocate.html index 0a746524b3..c3cda38429 100644 --- a/InvenTree/build/templates/build/allocate.html +++ b/InvenTree/build/templates/build/allocate.html @@ -1,47 +1,59 @@ {% extends "base.html" %} {% load static %} +{% load inventree_extras %} {% block content %}

Allocate Parts for Build

{{ build.title }}

- +{{ build.quantity }} x {{ build.part.name }}
+{% for bom_item in bom_items.all %} +{% include "build/allocation_item.html" with item=bom_item build=build %} +{% endfor %} +
+
+ +
+ {% endblock %} {% block js_load %} {{ block.super }} + {% 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 %} \ No newline at end of file +{% endblock %} diff --git a/InvenTree/build/templates/build/allocation_item.html b/InvenTree/build/templates/build/allocation_item.html new file mode 100644 index 0000000000..9982457aca --- /dev/null +++ b/InvenTree/build/templates/build/allocation_item.html @@ -0,0 +1,38 @@ +{% load inventree_extras %} + +
+
+
+
+ +
+ Required: +
+
+ {% multiply build.quantity item.quantity %} +
+
+ Allocated: +
+
+ 0 +
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
\ No newline at end of file diff --git a/InvenTree/build/templates/build/complete.html b/InvenTree/build/templates/build/complete.html new file mode 100644 index 0000000000..506f766c44 --- /dev/null +++ b/InvenTree/build/templates/build/complete.html @@ -0,0 +1,24 @@ +{% extends "modal_form.html" %} + +{% block pre_form_content %} +Build: {{ build.title }} - {{ build.quantity }} x {{ build.part.name }} +
+Are you sure you want to mark this build as complete? +
+{% if taking %} +The following items will be removed from stock: + +{% else %} +No parts have been allocated to this build. +{% endif %} +
+The following items will be created: + + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/build/templates/build/delete_build_item.html b/InvenTree/build/templates/build/delete_build_item.html new file mode 100644 index 0000000000..23993feb95 --- /dev/null +++ b/InvenTree/build/templates/build/delete_build_item.html @@ -0,0 +1,3 @@ +Are you sure you want to unallocate these parts? +
+This will remove {{ item.quantity }} parts from build '{{ item.build.title }}'. \ No newline at end of file diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index ad25b4307d..1960ed4ef5 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -38,14 +38,19 @@ Quantity{{ build.quantity }} + + Status{% include "build_status.html" with build=build %} + {% if build.batch %} Batch{{ build.batch }} {% endif %} +{% if build.URL %} - Status{% include "build_status.html" with build=build %} + URL{{ build.URL }} +{% endif %} Created{{ build.creation_date }} @@ -73,6 +78,7 @@ {% endif %} +{% if build.is_active %}

Required Parts

@@ -93,6 +99,8 @@
+{% 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 %} diff --git a/InvenTree/build/templatetags/__init__.py b/InvenTree/build/templatetags/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/InvenTree/build/templatetags/inventree_extras.py b/InvenTree/build/templatetags/inventree_extras.py new file mode 100644 index 0000000000..cb8cd0d235 --- /dev/null +++ b/InvenTree/build/templatetags/inventree_extras.py @@ -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 diff --git a/InvenTree/build/urls.py b/InvenTree/build/urls.py index fea3b6596c..162b9a613f 100644 --- a/InvenTree/build/urls.py +++ b/InvenTree/build/urls.py @@ -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\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\d+)/', include(build_detail_urls)), diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py index dc7b3141c3..f7de8a3a54 100644 --- a/InvenTree/build/views.py +++ b/InvenTree/build/views.py @@ -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 diff --git a/InvenTree/company/templates/company/tabs.html b/InvenTree/company/templates/company/tabs.html index 7c8c03d484..df8b9a4fc9 100644 --- a/InvenTree/company/templates/company/tabs.html +++ b/InvenTree/company/templates/company/tabs.html @@ -19,4 +19,4 @@ {% endif %} {% endif %} - \ No newline at end of file + diff --git a/InvenTree/part/admin.py b/InvenTree/part/admin.py index 4e63bf65e0..56bf290739 100644 --- a/InvenTree/part/admin.py +++ b/InvenTree/part/admin.py @@ -19,7 +19,7 @@ class PartCategoryAdmin(ImportExportModelAdmin): class PartAttachmentAdmin(admin.ModelAdmin): - list_display = ('part', 'attachment') + list_display = ('part', 'attachment', 'comment') class BomItemAdmin(ImportExportModelAdmin): diff --git a/InvenTree/part/migrations/0013_auto_20190429_2229.py b/InvenTree/part/migrations/0013_auto_20190429_2229.py new file mode 100644 index 0000000000..9c339b18b1 --- /dev/null +++ b/InvenTree/part/migrations/0013_auto_20190429_2229.py @@ -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'), + ), + ] diff --git a/InvenTree/part/migrations/0014_partattachment_comment.py b/InvenTree/part/migrations/0014_partattachment_comment.py new file mode 100644 index 0000000000..a51f588a59 --- /dev/null +++ b/InvenTree/part/migrations/0014_partattachment_comment.py @@ -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), + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 04d5471f92..19682929cf 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -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 diff --git a/InvenTree/part/templates/part/bom.html b/InvenTree/part/templates/part/bom.html index 805f501dcd..c178ef455c 100644 --- a/InvenTree/part/templates/part/bom.html +++ b/InvenTree/part/templates/part/bom.html @@ -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 %} diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 1d92ee651a..7ea8e5efbf 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -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: diff --git a/InvenTree/static/css/inventree.css b/InvenTree/static/css/inventree.css index 14b021adc7..0a1c34036b 100644 --- a/InvenTree/static/css/inventree.css +++ b/InvenTree/static/css/inventree.css @@ -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%); -} \ No newline at end of file +} + +.part-allocation-pass { + background: #dbf0db; +} + +.part-allocation-underallocated { + background: #f0dbdb; +} + +.part-allocation-overallocated { + background: #ccf5ff; +} diff --git a/InvenTree/static/script/inventree/bom.js b/InvenTree/static/script/inventree/bom.js index cc533f5201..1b58b81a93 100644 --- a/InvenTree/static/script/inventree/bom.js +++ b/InvenTree/static/script/inventree/bom.js @@ -114,6 +114,41 @@ function loadBomTable(table, options) { } ); + if (options.editable) { + cols.push({ + formatter: function(value, row, index, field) { + var bEdit = ""; + var bDelt = ""; + + return "
" + bEdit + bDelt + "
"; + } + }); + } + 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 = "" + value + ""; + } + else + { + text = "" + value + ""; + } + + 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 = ""; - var bDelt = ""; - - return "
" + bEdit + bDelt + "
"; - } - }); - } - 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 = "" + value + ""; - } - else - { - text = "" + value + ""; - } - - 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) { diff --git a/InvenTree/static/script/inventree/build.js b/InvenTree/static/script/inventree/build.js new file mode 100644 index 0000000000..826e2b1661 --- /dev/null +++ b/InvenTree/static/script/inventree/build.js @@ -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 = ""; + var bDel = ""; + + html += "
" + bEdit + bDel + "
"; + + 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'); + } + }); + }); + +} \ No newline at end of file diff --git a/InvenTree/static/script/inventree/part.js b/InvenTree/static/script/inventree/part.js index 9396e01772..053355113b 100644 --- a/InvenTree/static/script/inventree/part.js +++ b/InvenTree/static/script/inventree/part.js @@ -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); } \ No newline at end of file diff --git a/InvenTree/static/script/inventree/tables.js b/InvenTree/static/script/inventree/tables.js index 1ff1903326..979a7d5a21 100644 --- a/InvenTree/static/script/inventree/tables.js +++ b/InvenTree/static/script/inventree/tables.js @@ -2,10 +2,12 @@ function editButton(url, text='Edit') { return ""; } + function deleteButton(url, text='Delete') { return ""; } + function renderLink(text, url) { if (text === '' || url === '') { return text; @@ -14,51 +16,6 @@ function renderLink(text, url) { return '' + text + ''; } -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 = "" + text + ""; - - return html; -} function enableButtons(elements, enabled) { for (let item of elements) { diff --git a/InvenTree/stock/migrations/0010_auto_20190501_2344.py b/InvenTree/stock/migrations/0010_auto_20190501_2344.py new file mode 100644 index 0000000000..61ea730b03 --- /dev/null +++ b/InvenTree/stock/migrations/0010_auto_20190501_2344.py @@ -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), + ), + ] diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 2f61c068cd..cb0ad8aea4 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -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 diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 0a863aa7f9..269e3b4e78 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -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', ] diff --git a/InvenTree/templates/modal_form.html b/InvenTree/templates/modal_form.html index 1318e4f238..566671c657 100644 --- a/InvenTree/templates/modal_form.html +++ b/InvenTree/templates/modal_form.html @@ -1,3 +1,6 @@ +{% block pre_form_content %} +{% endblock %} + {% if form.non_field_errors %}