This commit is contained in:
James Newlands 2018-04-17 00:03:09 +10:00
commit 0a2c48eda6
86 changed files with 1129 additions and 706 deletions

View File

@ -7,6 +7,7 @@ from rest_framework.exceptions import ValidationError
from django.db.models.signals import pre_delete
from django.dispatch import receiver
class Company(models.Model):
""" Abstract model representing an external company
"""
@ -89,6 +90,10 @@ class InvenTreeTree(models.Model):
return unique
@property
def has_children(self):
return self.children.count() > 0
@property
def children(self):
contents = ContentType.objects.get_for_model(type(self))
@ -185,7 +190,7 @@ class InvenTreeTree(models.Model):
@receiver(pre_delete, sender=InvenTreeTree, dispatch_uid='tree_pre_delete_log')
def before_delete_tree_item(sender, intance, using, **kwargs):
def before_delete_tree_item(sender, instance, using, **kwargs):
# Update each tree item below this one
for child in instance.children.all():

View File

@ -36,6 +36,7 @@ INSTALLED_APPS = [
'rest_framework',
'simple_history',
'crispy_forms',
'import_export',
# Core django modules
'django.contrib.admin',
@ -47,9 +48,8 @@ INSTALLED_APPS = [
# InvenTree apps
'part.apps.PartConfig',
'supplier.apps.SupplierConfig',
'stock.apps.StockConfig',
'track.apps.TrackConfig',
'supplier.apps.SupplierConfig',
]
MIDDLEWARE = [
@ -68,7 +68,7 @@ ROOT_URLCONF = 'InvenTree.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'DIRS': [os.path.join(BASE_DIR, 'templates')],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
@ -81,6 +81,8 @@ TEMPLATES = [
},
]
print(os.path.join(BASE_DIR, 'templates'))
REST_FRAMEWORK = {
'EXCEPTION_HANDLER': 'InvenTree.utils.api_exception_handler'
}
@ -145,4 +147,8 @@ MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
# crispy forms use the bootstrap templates
CRISPY_TEMPLATE_PACK = 'bootstrap'
# Use database transactions when importing / exporting data
IMPORT_EXPORT_USE_TRANSACTIONS = True

View File

@ -1,17 +1,14 @@
from django.conf.urls import url, include
from django.contrib import admin
from rest_framework.documentation import include_docs_urls
from part.urls import part_api_urls, part_cat_api_urls
from part.urls import bom_api_urls
from part.urls import part_urls
from stock.urls import stock_api_urls, stock_api_loc_urls
from stock.urls import stock_urls
#from supplier.urls import supplier_api_urls, supplier_api_part_urls
# from supplier.urls import supplier_api_urls, supplier_api_part_urls
from supplier.urls import supplier_urls
from django.conf import settings
@ -19,10 +16,8 @@ from django.conf.urls.static import static
from django.views.generic.base import RedirectView
from track.urls import tracking_urls
#from project.urls import prj_urls, prj_part_urls, prj_cat_urls, prj_run_urls
#from track.urls import unique_urls, part_track_urls
# from project.urls import prj_urls, prj_part_urls, prj_cat_urls, prj_run_urls
# from track.urls import unique_urls, part_track_urls
from users.urls import user_urls
@ -37,28 +32,28 @@ apipatterns = [
# Part URLs
url(r'^part/', include(part_api_urls)),
url(r'^part-category/', include(part_cat_api_urls)),
#url(r'^part-param/', include(part_param_urls)),
#url(r'^part-param-template/', include(part_param_template_urls)),
# url(r'^part-param/', include(part_param_urls)),
# url(r'^part-param-template/', include(part_param_template_urls)),
# Part BOM URLs
url(r'^bom/', include(bom_api_urls)),
# Supplier URLs
#url(r'^supplier/', include(supplier_api_urls)),
#url(r'^supplier-part/', include(supplier_api_part_urls)),
#url(r'^price-break/', include(price_break_urls)),
#url(r'^manufacturer/', include(manu_urls)),
#url(r'^customer/', include(cust_urls)),
# url(r'^supplier/', include(supplier_api_urls)),
# url(r'^supplier-part/', include(supplier_api_part_urls)),
# url(r'^price-break/', include(price_break_urls)),
# url(r'^manufacturer/', include(manu_urls)),
# url(r'^customer/', include(cust_urls)),
# Tracking URLs
#url(r'^track/', include(part_track_urls)),
#url(r'^unique-part/', include(unique_urls)),
# url(r'^track/', include(part_track_urls)),
# url(r'^unique-part/', include(unique_urls)),
# Project URLs
#url(r'^project/', include(prj_urls)),
#url(r'^project-category/', include(prj_cat_urls)),
#url(r'^project-part/', include(prj_part_urls)),
#url(r'^project-run/', include(prj_run_urls)),
# url(r'^project/', include(prj_urls)),
# url(r'^project-category/', include(prj_cat_urls)),
# url(r'^project-part/', include(prj_part_urls)),
# url(r'^project-run/', include(prj_run_urls)),
# User URLs
url(r'^user/', include(user_urls)),
@ -67,13 +62,12 @@ apipatterns = [
urlpatterns = [
# API URL
#url(r'^api/', include(apipatterns)),
#url(r'^api-doc/', include_docs_urls(title='InvenTree API')),
# url(r'^api/', include(apipatterns)),
# url(r'^api-doc/', include_docs_urls(title='InvenTree API')),
url(r'^part/', include(part_urls)),
url(r'^stock/', include(stock_urls)),
url(r'^supplier/', include(supplier_urls)),
url(r'^track/', include(tracking_urls)),
url(r'^admin/', admin.site.urls),
url(r'^auth/', include('rest_framework.urls', namespace='rest_framework')),
@ -87,4 +81,4 @@ if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
# Send any unknown URLs to the parts page
urlpatterns += [url(r'^.*$', RedirectView.as_view(url='part/', permanent=False), name='part-index')]
urlpatterns += [url(r'^.*$', RedirectView.as_view(url='/part/', permanent=False), name='part-index')]

View File

@ -1,24 +1,29 @@
from django.contrib import admin
from import_export.admin import ImportExportModelAdmin
from .models import PartCategory, Part
from .models import BomItem
from .models import PartAttachment
class PartAdmin(admin.ModelAdmin):
list_display = ('name', 'IPN', 'description', 'stock', 'category')
class PartAdmin(ImportExportModelAdmin):
list_display = ('name', 'IPN', 'description', 'total_stock', 'category')
class PartCategoryAdmin(admin.ModelAdmin):
list_display = ('name', 'pathstring', 'description')
class BomItemAdmin(admin.ModelAdmin):
list_display=('part', 'sub_part', 'quantity')
class BomItemAdmin(ImportExportModelAdmin):
list_display = ('part', 'sub_part', 'quantity')
class PartAttachmentAdmin(admin.ModelAdmin):
list_display = ('part', 'attachment')
"""
class ParameterTemplateAdmin(admin.ModelAdmin):
list_display = ('name', 'units', 'format')
@ -33,6 +38,6 @@ admin.site.register(PartCategory, PartCategoryAdmin)
admin.site.register(BomItem, BomItemAdmin)
admin.site.register(PartAttachment, PartAttachmentAdmin)
#admin.site.register(PartParameter, ParameterAdmin)
#admin.site.register(PartParameterTemplate, ParameterTemplateAdmin)
#admin.site.register(CategoryParameterLink)
# admin.site.register(PartParameter, ParameterAdmin)
# admin.site.register(PartParameterTemplate, ParameterTemplateAdmin)
# admin.site.register(CategoryParameterLink)

View File

@ -8,6 +8,9 @@ from django_filters.rest_framework import FilterSet, DjangoFilterBackend
from .models import PartCategory, Part, BomItem
from InvenTree.models import FilterChildren
class PartDetail(generics.RetrieveUpdateDestroyAPIView):
"""
@ -69,6 +72,7 @@ class PartParamDetail(generics.RetrieveUpdateDestroyAPIView):
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
"""
class PartFilter(FilterSet):
class Meta:
@ -174,6 +178,7 @@ class PartTemplateList(generics.ListCreateAPIView):
"""
class BomItemDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = BomItem.objects.all()
@ -190,9 +195,6 @@ class BomItemFilter(FilterSet):
class BomItemList(generics.ListCreateAPIView):
#def get_queryset(self):
# params = self.request.
queryset = BomItem.objects.all()
serializer_class = BomItemSerializer
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)

View File

@ -2,7 +2,7 @@ from django import forms
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit
from .models import Part, PartCategory
from .models import Part, PartCategory, BomItem
class EditPartForm(forms.ModelForm):
@ -12,9 +12,7 @@ class EditPartForm(forms.ModelForm):
self.helper = FormHelper()
self.helper.form_id = 'id-edit-part-form'
#self.helper.form_class = 'blueForms'
self.helper.form_method = 'post'
#self.helper.form_action = 'submit'
self.helper.add_input(Submit('submit', 'Submit'))
@ -27,7 +25,9 @@ class EditPartForm(forms.ModelForm):
'IPN',
'URL',
'minimum_stock',
'buildable',
'trackable',
'purchaseable',
]
@ -38,9 +38,7 @@ class EditCategoryForm(forms.ModelForm):
self.helper = FormHelper()
self.helper.form_id = 'id-edit-part-form'
#self.helper.form_class = 'blueForms'
self.helper.form_method = 'post'
#self.helper.form_action = 'submit'
self.helper.add_input(Submit('submit', 'Submit'))
@ -50,4 +48,24 @@ class EditCategoryForm(forms.ModelForm):
'parent',
'name',
'description'
]
]
class EditBomItemForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super(EditBomItemForm, self).__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_id = 'id-edit-part-form'
self.helper.form_method = 'post'
self.helper.add_input(Submit('submit', 'Submit'))
class Meta:
model = BomItem
fields = [
'part',
'sub_part',
'quantity'
]

View File

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2018-04-15 14:21
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0016_auto_20180415_0316'),
]
operations = [
migrations.AddField(
model_name='part',
name='purchaseable',
field=models.BooleanField(default=True),
),
]

View File

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2018-04-16 12:08
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0017_part_purchaseable'),
]
operations = [
migrations.AddField(
model_name='part',
name='buildable',
field=models.BooleanField(default=False),
),
]

View File

@ -0,0 +1,62 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2018-04-16 12:49
from __future__ import unicode_literals
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('part', '0018_part_buildable'),
]
operations = [
migrations.AlterField(
model_name='part',
name='IPN',
field=models.CharField(blank=True, help_text='Internal Part Number', max_length=100),
),
migrations.AlterField(
model_name='part',
name='URL',
field=models.URLField(blank=True, help_text='Link to extenal URL'),
),
migrations.AlterField(
model_name='part',
name='buildable',
field=models.BooleanField(default=False, help_text='Can this part be built from other parts?'),
),
migrations.AlterField(
model_name='part',
name='category',
field=models.ForeignKey(blank=True, help_text='Part category', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='parts', to='part.PartCategory'),
),
migrations.AlterField(
model_name='part',
name='description',
field=models.CharField(help_text='Part description', max_length=250),
),
migrations.AlterField(
model_name='part',
name='minimum_stock',
field=models.PositiveIntegerField(default=0, help_text='Minimum allowed stock level', validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AlterField(
model_name='part',
name='name',
field=models.CharField(help_text='Part name (must be unique)', max_length=100, unique=True),
),
migrations.AlterField(
model_name='part',
name='purchaseable',
field=models.BooleanField(default=True, help_text='Can this part be purchased from external suppliers?'),
),
migrations.AlterField(
model_name='part',
name='trackable',
field=models.BooleanField(default=False, help_text='Does this part have tracking for unique items?'),
),
]

View File

@ -1,5 +1,4 @@
from __future__ import unicode_literals
from django.utils.translation import ugettext as _
from django.db import models
from django.db.models import Sum
from django.core.validators import MinValueValidator
@ -23,7 +22,6 @@ class PartCategory(InvenTreeTree):
verbose_name = "Part Category"
verbose_name_plural = "Part Categories"
@property
def partcount(self):
""" Return the total part count under this category
@ -37,11 +35,10 @@ class PartCategory(InvenTreeTree):
return count
"""
@property
def parts(self):
return self.part_set.all()
"""
def has_parts(self):
return self.parts.count() > 0
@receiver(pre_delete, sender=PartCategory, dispatch_uid='partcategory_delete_log')
def before_delete_part_category(sender, instance, using, **kwargs):
@ -85,36 +82,44 @@ class Part(models.Model):
return '/part/{id}/'.format(id=self.id)
# Short name of the part
name = models.CharField(max_length=100, unique=True)
name = models.CharField(max_length=100, unique=True, help_text='Part name (must be unique)')
# Longer description of the part (optional)
description = models.CharField(max_length=250)
description = models.CharField(max_length=250, help_text='Part description')
# Internal Part Number (optional)
# Potentially multiple parts map to the same internal IPN (variants?)
# So this does not have to be unique
IPN = models.CharField(max_length=100, blank=True)
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)
URL = models.URLField(blank=True, help_text='Link to extenal URL')
# Part category - all parts must be assigned to a category
category = models.ForeignKey(PartCategory, related_name='parts',
null=True, blank=True,
on_delete=models.DO_NOTHING)
on_delete=models.DO_NOTHING,
help_text='Part category')
image = models.ImageField(upload_to=rename_part_image, max_length=255, null=True, blank=True)
# Minimum "allowed" stock level
minimum_stock = models.PositiveIntegerField(default=0, validators=[MinValueValidator(0)])
minimum_stock = models.PositiveIntegerField(default=0, validators=[MinValueValidator(0)], help_text='Minimum allowed stock level')
# Units of quantity for this part. Default is "pcs"
units = models.CharField(max_length=20, default="pcs", blank=True)
# Can this part be built?
buildable = models.BooleanField(default=False, help_text='Can this part be built from other parts?')
# Is this part "trackable"?
# Trackable parts can have unique instances which are assigned serial numbers
# Trackable parts can have unique instances
# which are assigned serial numbers (or batch numbers)
# and can have their movements tracked
trackable = models.BooleanField(default=False)
trackable = models.BooleanField(default=False, help_text='Does this part have tracking for unique items?')
# Is this part "purchaseable"?
purchaseable = models.BooleanField(default=True, help_text='Can this part be purchased from external suppliers?')
def __str__(self):
if self.IPN:
@ -127,10 +132,41 @@ class Part(models.Model):
class Meta:
verbose_name = "Part"
verbose_name_plural = "Parts"
#unique_together = (("name", "category"),)
@property
def stock(self):
def available_stock(self):
"""
Return the total available stock.
This subtracts stock which is already allocated
"""
# TODO - For now, just return total stock count
# TODO - In future must take account of allocated stock
return self.total_stock
@property
def can_build(self):
""" Return the number of units that can be build with available stock
"""
# If this part does NOT have a BOM, result is simply the currently available stock
if not self.has_bom:
return self.available_stock
total = None
# Calculate the minimum number of parts that can be built using each sub-part
for item in self.bom_items.all():
stock = item.sub_part.available_stock
n = int(1.0 * stock / item.quantity)
if total is None or n < total:
total = n
return total
@property
def total_stock(self):
""" Return the total stock quantity for this part.
Part may be stored in multiple locations
"""
@ -143,13 +179,21 @@ class Part(models.Model):
return result['total']
@property
def bomItemCount(self):
return self.bom_items.all().count()
def has_bom(self):
return self.bom_count > 0
@property
def usedInCount(self):
return self.used_in.all().count()
def bom_count(self):
return self.bom_items.count()
@property
def used_in_count(self):
return self.used_in.count()
@property
def supplier_count(self):
# Return the number of supplier parts available for this part
return self.supplier_parts.count()
"""
@property
@ -171,9 +215,10 @@ class Part(models.Model):
return projects
"""
def attach_file(instance, filename):
base='part_files'
base = 'part_files'
# TODO - For a new PartAttachment object, PK is NULL!!
@ -182,25 +227,27 @@ def attach_file(instance, filename):
return os.path.join(base, fn)
class PartAttachment(models.Model):
""" A PartAttachment links a file to a part
Parts can have multiple files such as datasheets, etc
"""
part = models.ForeignKey(Part, on_delete=models.CASCADE,
related_name='attachments')
attachment = models.FileField(upload_to=attach_file, null=True, blank=True)
class BomItem(models.Model):
""" A BomItem links a part to its component items.
A part can have a BOM (bill of materials) which defines
which parts are required (and in what quatity) to make it
"""
def get_absolute_url(self):
return '/part/bom/{id}/'.format(id=self.id)
# A link to the parent part
# Each part will get a reverse lookup field 'bom_items'
part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='bom_items')
@ -212,7 +259,6 @@ class BomItem(models.Model):
# Quantity required
quantity = models.PositiveIntegerField(default=1, validators=[MinValueValidator(0)])
class Meta:
verbose_name = "BOM Item"

View File

@ -3,6 +3,7 @@ from rest_framework import serializers
from .models import Part, PartCategory
from .models import BomItem
class BomItemSerializer(serializers.ModelSerializer):
class Meta:
@ -12,6 +13,7 @@ class BomItemSerializer(serializers.ModelSerializer):
'sub_part',
'quantity')
"""
class PartParameterSerializer(serializers.HyperlinkedModelSerializer):
" Serializer for a PartParameter
@ -27,7 +29,7 @@ class PartParameterSerializer(serializers.HyperlinkedModelSerializer):
'units')
"""
#class PartSerializer(serializers.HyperlinkedModelSerializer):
class PartSerializer(serializers.ModelSerializer):
""" Serializer for complete detail information of a part.
Used when displaying all details of a single component.
@ -56,6 +58,7 @@ class PartCategorySerializer(serializers.HyperlinkedModelSerializer):
'parent',
'pathstring')
"""
class PartTemplateSerializer(serializers.HyperlinkedModelSerializer):
@ -65,4 +68,4 @@ class PartTemplateSerializer(serializers.HyperlinkedModelSerializer):
'name',
'units',
'format')
"""
"""

View File

@ -12,7 +12,6 @@ Deletion title goes here
<p><b>This is a permanent action and cannot be undone.</b></p>
{% block del_body %}
Deletion body goes here
{% endblock %}
<form action="" method="post">{% csrf_token %}

View File

@ -9,7 +9,6 @@
<li><a href="/part/">Parts</a></li>
<li><a href="/stock/">Stock</a></li>
<li><a href="/supplier/">Suppliers</a></li>
<li><a href="/track/">Tracking</a></li>
</ul>
</div>
</nav>

View File

@ -0,0 +1,5 @@
{% extends 'create_edit_obj.html' %}
{% block obj_title %}
Create a new BOM item
{% endblock %}

View File

@ -0,0 +1,15 @@
{% extends "delete_obj.html" %}
{% block del_title %}
Are you sure you want to delete this BOM item?
{% endblock %}
{% block del_body %}
Deleting this entry will remove the BOM row from the following part:
<ul class='list-group'>
<li class='list-group-item'>
<b>{{ item.part.name }}</b> - <i>{{ item.part.description }}</i>
</li>
</ul>
{% endblock %}

View File

@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block content %}
<h3>BOM Item</h3>
<table class="table table-striped">
<tr><td>Parent</td><td><a href="{% url 'part-bom' item.part.id %}">{{ item.part.name }}</a></td></tr>
<tr><td>Child</td><td><a href="{% url 'part-used-in' item.sub_part.id %}">{{ item.sub_part.name }}</a></td></tr>
<tr><td>Quantity</td><td>{{ item.quantity }}</td></tr>
</table>
<div class='container-fluid'>
<a href="{% url 'bom-item-edit' item.id %}"><button class="btn btn-info">Edit BOM item</button></a>
<a href="{% url 'bom-item-delete' item.id %}"><button class="btn btn-danger">Delete BOM item</button></a>
</div>
{% endblock %}

View File

@ -0,0 +1,5 @@
{% extends 'create_edit_obj.html' %}
{% block obj_title %}
Edit details for BOM item
{% endblock %}

View File

@ -4,21 +4,31 @@
{% include 'part/tabs.html' with tab='bom' %}
<h3>Bill of Materials</h3>
<table class="table table-striped">
<tr>
<th>Part</th>
<th>Description</th>
<th>Quantity</th>
<th>Edit</th>
</tr>
{% for bom_item in part.bom_items.all %}
{% with sub_part=bom_item.sub_part %}
<tr>
<td><a href="{% url 'part-detail' sub_part.id %}">{{ sub_part.name }}</a></td>
<td>{{ sub_part.description }}</td>
<td>{{ bom_item.quantity }}</td>
<td>{{ bom_item.quantity }}</span></td>
<td><a href="{% url 'bom-item-detail' bom_item.id %}">Edit</a></td>
</tr>
{% endwith %}
{% endfor %}
</table>
<div class='container-fluid'>
<a href="{% url 'bom-item-create' %}?parent={{ part.id }}">
<button class='btn btn-success'>Add BOM Item</button>
</a>
</div>
{% endblock %}

View File

@ -0,0 +1,13 @@
{% extends "part/part_base.html" %}
{% block details %}
{% include 'part/tabs.html' with tab='build' %}
<h3>Build Part</h3>
TODO
<br><br>
You can build {{ part.can_build }} of this part with current stock.
{% endblock %}

View File

@ -18,7 +18,7 @@ Are you sure you want to delete category '{{ category.name }}'?
<ul class='list-group'>
{% for cat in category.children.all %}
<li class='list-group-item'>{{ cat.name }} - {{ cat.description }}</li>
<li class='list-group-item'><b>{{ cat.name }}</b> - <i>{{ cat.description }}</i></li>
{% endfor %}
</ul>
{% endif %}
@ -33,7 +33,7 @@ Are you sure you want to delete category '{{ category.name }}'?
</p>
<ul class='list-group'>
{% for part in category.parts.all %}
<li class='list-group-item'>{{ part.name }} - {{ part.description }}</li>
<li class='list-group-item'><b>{{ part.name }}</b> - <i>{{ part.description }}</i></li>
{% endfor %}
</ul>
{% endif %}

View File

@ -11,9 +11,15 @@
<i>{{ category.description }}</i>
</p>
{% if category.has_children %}
<h4>Subcategories</h4>
{% include "part/category_subcategories.html" with children=category.children.all %}
{% endif %}
{% if category.has_parts %}
<h4>Parts</h4>
{% include "part/category_parts.html" with parts=category.parts.all %}
{% endif %}
<div class='container-fluid'>
<a href="{% url 'category-create' %}?category={{ category.id }}">

View File

@ -1,5 +1,3 @@
{% if parts|length > 0 %}
Parts:
<table class="table table-striped">
<tr>
<th>Part</th>
@ -11,5 +9,4 @@ Parts:
<td>{{ part.description }}</td>
</tr>
{% endfor %}
</table>
{% endif %}
</table>

View File

@ -1,5 +1,3 @@
{% if children|length > 0 %}
Subcategories:
<ul class="list-group">
{% for child in children %}
<li class="list-group-item">
@ -10,5 +8,4 @@ Subcategories:
<span class='badge'>{{ child.partcount }}</span>
</li>
{% endfor %}
</ul>
{% endif %}
</ul>

View File

@ -7,8 +7,8 @@
{% block del_body %}
{% if part.usedInCount > 0 %}
<p>This part is used in BOMs for {{ part.usedInCount }} other parts. If you delete this part, the BOMs for the following parts will be updated:
{% if part.used_in_count %}
<p>This part is used in BOMs for {{ part.used_in_count }} other parts. If you delete this part, the BOMs for the following parts will be updated:
<ul class="list-group">
{% for child in part.used_in.all %}
<li class='list-group-item'>{{ child.part.name }} - {{ child.part.description }}</li>

View File

@ -4,12 +4,52 @@
{% include 'part/tabs.html' with tab='detail' %}
<h3>Part Details</h3>
Part details go here...
<br>
<table class='table table-striped'>
<tr>
<td>Part name</td>
<td>{{ part.name }}</td>
</tr>
<tr>
<td>Description</td>
<td>{{ part.decription }}</td>
</tr>
<tr>
<td>Category</td>
<td>
{% if part.category %}
<a href="{% url 'category-detail' part.category.id %}">{{ part.category.name }}</a>
{% endif %}
</td>
</tr>
<tr>
<td>Units</td>
<td>{{ part.units }}</td>
</tr>
<tr>
<td>Buildable</td>
<td>{{ part.buildable }}</td>
</tr>
<tr>
<td>Trackable</td>
<td>{{ part.trackable }}</td>
</tr>
<tr>
<td>Purchaseable</td>
<td>{{ part.purchaseable }}</td>
</tr>
{% if part.minimum_stock > 0 %}
<tr>
<td>Minimum Stock</td>
<td>{{ part.minimum_stock }}</td>
</tr>
{% endif %}
</table>
<div class='container-fluid'>
<a href="{% url 'part-edit' part.id %}"><button class="btn btn-info">Edit Part</button></a>
<a href="{% url 'part-delete' part.id %}"><button class="btn btn-danger">Delete Part</button></a>
</div>
{% endblock %}

View File

@ -5,9 +5,15 @@
{% include "part/cat_link.html" with category=category %}
{% if children.all|length > 0 %}
<h4>Part Categories</h4>
{% include "part/category_subcategories.html" with children=children %}
{% endif %}
{% if parts.all|length > 0%}
<h4>Top Level Parts</h4>
{% include "part/category_parts.html" with parts=parts %}
{% endif %}
<div class='container-fluid'>
<a href="{% url 'category-create' %}">

View File

@ -6,33 +6,74 @@
{% include "part/cat_link.html" with category=part.category %}
<div class="media">
<div class="media-left">
<img class="part-thumb"
{% if part.image %}
src="{{ part.image.url }}"
{% else %}
src="{% static 'img/blank_image.png' %}"
{% endif %}/>
</div>
<div class="media-body">
<h4>{{ part.name }}</h4>
{% if part.description %}
<p><i>{{ part.description }}</i></p>
{% endif %}
{% if part.IPN %}
<p><b>IPN:</b> {{ part.IPN }}</p>
{% endif %}
{% if part.URL %}
<p>{% include 'url.html' with url=part.URL %}</p>
{% endif %}
<div class="row">
<div class="col-sm-6">
<div class="media">
<div class="media-left">
<img class="part-thumb"
{% if part.image %}
src="{{ part.image.url }}"
{% else %}
src="{% static 'img/blank_image.png' %}"
{% endif %}/>
</div>
<div class="media-body">
<h4>{{ part.name }}</h4>
{% if part.description %}
<p><i>{{ part.description }}</i></p>
{% endif %}
</div>
</div>
</div>
<div class="col-sm-6">
<table class="table table-striped">
{% if part.IPN %}
<tr>
<td>IPN</td>
<td>{{ part.IPN }}</td>
</tr>
{% endif %}
{% if part.URL %}
<tr>
<td>URL</td>
<td><a href="{{ part.URL }}">{{ part.URL }}</a></td>
</tr>
{% endif %}
<tr>
<td>Available Stock</td>
<td>
{% if part.available_stock == 0 %}
<span class='label label-danger'>{{ part.available_stock }}</span>
{% elif part.available_stock < part.minimum_stock %}
<span class='label label-warning'>{{ part.available_stock }}</span>
{% else %}
{{ part.available_stock }}
{% endif %}
</td>
</tr>
{% if part.buildable %}
<tr>
<td>Can Build</td>
<td>
{% if part.can_build == 0 %}
<span class='label label-danger'>0</span>
{% else %}
{{ part.can_build }}
{% endif %}
</td>
</tr>
{% endif %}
</table>
</div>
</div>
<hr>
<div class='container-fluid'>
{% block details %}
<!-- Specific part details go here... -->
{% endblock %}
</div>
{% endblock %}

View File

@ -4,12 +4,11 @@
{% include 'part/tabs.html' with tab='stock' %}
<br>
Total in stock: {{ part.stock }}
<br>
<h3>Part Stock</h3>
<table class="table table-striped">
<tr>
<th>Link</th>
<th>Quantity</th>
<th>Location</th>
<th>Supplier part</th>
@ -18,8 +17,9 @@ Total in stock: {{ part.stock }}
</tr>
{% for stock in part.locations.all %}
<tr>
<td><a href="{% url 'stock-item-detail' stock.id %}">Click</a></td>
<td>{{ stock.quantity }}</td>
<td><a href="/stock/list/?location={{ stock.location.id }}">{{ stock.location.name }}</a></td>
<td><a href="{% url 'stock-location-detail' stock.location.id %}">{{ stock.location.name }}</a></td>
<td>
{% if stock.supplier_part %}
<a href="{% url 'supplier-part-detail' stock.supplier_part.id %}">
@ -31,6 +31,12 @@ Total in stock: {{ part.stock }}
<td>{{ stock.notes }}</td>
</tr>
{% endfor %}
</table>
</table
<div class='container-fluid'>
<a href="{% url 'stock-item-create' %}?part={{ part.id }}">
<button class='btn btn-success'>Add new Stock Item</button>
</a>
</div>
{% endblock %}

View File

@ -4,17 +4,23 @@
{% include 'part/tabs.html' with tab='suppliers' %}
{% if part.supplier_parts.all|length > 0 %}
<h3>Part Suppliers</h3>
<table class="table table-striped">
<tr>
<th>Supplier</th>
<th>SKU</th>
<th>Supplier</th>
<th>MPN</th>
<th>URL</th>
</tr>
{% for spart in part.supplier_parts.all %}
<tr>
<td><a href="{% url 'supplier-detail' spart.supplier.id %}">{{ spart.supplier.name }}</a></td>
<td><a href="{% url 'supplier-part-detail' spart.id %}">{{ spart.SKU }}</a></td>
<td><a href="{% url 'supplier-detail' spart.supplier.id %}">{{ spart.supplier.name }}</a></td>
<td>
{% if spart.manufacturer %}{{ spart.manufacturer.name }}{% endif %}
{% if spart.MPN %} | {{ spart.MPN }}{% endif %}
</td>
<td>
{% if spart.URL %}
<a href="{{ spart.URL }}">{{ spart.URL }}</a>
@ -23,8 +29,11 @@
</tr>
{% endfor %}
</table>
{% else %}
There are no suppliers defined for this part, sorry!
{% endif %}
<div class='container-fluid'>
<a href="{% url 'supplier-part-create' %}?part={{ part.id }}">
<button class="btn btn-success">New Supplier Part</button>
</a>
</div>
{% endblock %}

View File

@ -1,15 +1,23 @@
<ul class="nav nav-tabs">
<li{% ifequal tab 'detail' %} class="active"{% endifequal %}><a href="{% url 'part-detail' part.id %}">Details</a></li>
<li{% ifequal tab 'bom' %} class="active"{% endifequal %}><a href="{% url 'part-bom' part.id %}">BOM <span class="badge">{{ part.bomItemCount }}</span></a></li>
{% if part.bomItemCount > 0 %}
<li{% ifequal tab 'build' %} class "active"{% endifequal %}><a href="#">Build</a></li>
{% if part.buildable %}
<li{% ifequal tab 'bom' %} class="active"{% endifequal %}><a href="{% url 'part-bom' part.id %}">BOM<span class="badge">{{ part.bom_count }}</span></a></li>
<li{% ifequal tab 'build' %} class="active"{% endifequal %}><a href="{% url 'part-build' part.id %}">Build<span class='badge'>{{ part.can_build }}</span></a></li>
{% endif %}
{% if part.usedInCount > 0 %}
<li{% ifequal tab 'used' %} class="active"{% endifequal %}><a href="{% url 'part-used-in' part.id %}">Used In <span class="badge">{{ part.usedInCount }}</span></a></li>
{% if part.used_in_count > 0 %}
<li{% ifequal tab 'used' %} class="active"{% endifequal %}><a href="{% url 'part-used-in' part.id %}">Used In{% if part.used_in_count > 0 %}<span class="badge">{{ part.used_in_count }}</span>{% endif %}</a></li>
{% endif %}
<li{% ifequal tab 'stock' %} class="active"{% endifequal %}><a href="{% url 'part-stock' part.id %}">Stock <span class="badge">{{ part.available_stock }}</span></a></li>
{% if part.purchaseable %}
<li{% ifequal tab 'suppliers' %} class="active"{% endifequal %}><a href="{% url 'part-suppliers' part.id %}">Suppliers
<span class="badge">{{ part.supplier_count }}<span>
</a></li>
{% endif %}
<li{% ifequal tab 'stock' %} class="active"{% endifequal %}><a href="{% url 'part-stock' part.id %}">Stock <span class="badge">{{ part.stock }}</span></a></li>
<li{% ifequal tab 'suppliers' %} class="active"{% endifequal %}><a href="{% url 'part-suppliers' part.id %}">Suppliers <span class="badge">{{ part.supplier_parts.all|length }}<span></a></li>
{% if part.trackable %}
<li{% ifequal tab 'track' %} class="active"{% endifequal %}><a href="{% url 'part-track' part.id %}">Tracking <span class="badge">{{ part.serials.all|length }}</span></a></li>
<li{% ifequal tab 'track' %} class="active"{% endifequal %}><a href="{% url 'part-track' part.id %}">Tracking
{% if parts.serials.all|length > 0 %}
<span class="badge">{{ part.serials.all|length }}</span>
{% endif %}
</a></li>
{% endif %}
</ul>

View File

@ -11,12 +11,18 @@ Part tracking for {{ part.name }}
<th>Serial</th>
<th>Status</th>
</tr>
{% for track in part.serials.all %}
{% for track in part.tracked_parts.all %}
<tr>
<td>{{ track.serial }}</td>
<td>{{ track.status }}</td>
<td><a href="{% url 'track-detail' track.id %}">{{ track.serial }}</a></td>
<td>{{ track.get_status_display }}</td>
</tr>
{% endfor %}
</table>
<div class='container-fluid'>
<a href="{% url 'track-create' %}?part={{ part.id }}">
<button class="btn btn-success">New Tracked Part</button>
</a>
</div>
{% endblock %}

View File

@ -4,7 +4,7 @@
{% include 'part/tabs.html' with tab='used' %}
This part is used to make the following parts:
<h3>Used In</h3>
<table class="table table-striped">
<tr>
@ -13,7 +13,7 @@ This part is used to make the following parts:
</tr>
{% for item in part.used_in.all %}
<tr>
<td><a href="{% url 'part-detail' item.part.id %}">{{ item.part.name }}</a></td>
<td><a href="{% url 'part-bom' item.part.id %}">{{ item.part.name }}</a></td>
<td>{{ item.part.description }}</td>
</tr>
{% endfor %}

View File

@ -41,12 +41,12 @@ part_detail_urls = [
url(r'^delete/?', views.PartDelete.as_view(), name='part-delete'),
url(r'^track/?', views.PartDetail.as_view(template_name='part/track.html'), name='part-track'),
url(r'^bom/?', views.PartDetail.as_view(template_name='part/bom.html'), name='part-bom'),
url(r'^build/?', views.PartDetail.as_view(template_name='part/build.html'), name='part-build'),
url(r'^stock/?', views.PartDetail.as_view(template_name='part/stock.html'), name='part-stock'),
url(r'^used/?', views.PartDetail.as_view(template_name='part/used_in.html'), name='part-used-in'),
url(r'^suppliers/?', views.PartDetail.as_view(template_name='part/supplier.html'), name='part-suppliers'),
# Any other URLs go to the part detail page
#url(r'^.*$', views.detail, name='part-detail'),
url(r'^.*$', views.PartDetail.as_view(), name='part-detail'),
]
@ -57,6 +57,13 @@ part_category_urls = [
url('^.*$', views.CategoryDetail.as_view(), name='category-detail'),
]
part_bom_urls = [
url(r'^edit/?', views.BomItemEdit.as_view(), name='bom-item-edit'),
url('^delete/?', views.BomItemDelete.as_view(), name='bom-item-delete'),
url(r'^.*$', views.BomItemDetail.as_view(), name='bom-item-detail'),
]
# URL list for part web interface
part_urls = [
@ -66,20 +73,23 @@ part_urls = [
# Create a new part
url(r'^new/?', views.PartCreate.as_view(), name='part-create'),
# Individual
# Create a new BOM item
url(r'^bom/new/?', views.BomItemCreate.as_view(), name='bom-item-create'),
# Individual part
url(r'^(?P<pk>\d+)/', include(part_detail_urls)),
# Part category
url(r'^category/(?P<pk>\d+)/', include(part_category_urls)),
url(r'^bom/(?P<pk>\d+)/', include(part_bom_urls)),
# Top level part list (display top level parts and categories)
url('', views.PartIndex.as_view(), name='part-index'),
url(r'^.*$', RedirectView.as_view(url='', permanent=False), name='part-index'),
url(r'^.*$', RedirectView.as_view(url='', permanent=False), name='part-index'),
]
"""
part_param_urls = [
# Detail of a single part parameter
@ -99,5 +109,3 @@ part_param_template_urls = [
url(r'^$', views.PartTemplateList.as_view())
]
"""

View File

@ -1,14 +1,13 @@
from InvenTree.models import FilterChildren
from .models import PartCategory, Part
from django.shortcuts import get_object_or_404, render
from django.shortcuts import get_object_or_404
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.views.generic import DetailView, ListView
from django.views.generic.edit import UpdateView, DeleteView, CreateView
from .forms import EditPartForm, EditCategoryForm
from .forms import EditPartForm, EditCategoryForm, EditBomItemForm
from .models import PartCategory, Part, BomItem
class PartIndex(ListView):
model = Part
@ -113,7 +112,7 @@ class CategoryDelete(DeleteView):
model = PartCategory
template_name = 'part/category_delete.html'
context_object_name = 'category'
success_url ='/part/'
success_url = '/part/'
def post(self, request, *args, **kwargs):
if 'confirm' in request.POST:
@ -146,3 +145,47 @@ class CategoryCreate(CreateView):
initials['parent'] = get_object_or_404(PartCategory, pk=parent_id)
return initials
class BomItemDetail(DetailView):
context_object_name = 'item'
queryset = BomItem.objects.all()
template_name = 'part/bom-detail.html'
class BomItemCreate(CreateView):
model = BomItem
form_class = EditBomItemForm
template_name = 'part/bom-create.html'
def get_initial(self):
# Look for initial values
initials = super(BomItemCreate, self).get_initial().copy()
# Parent part for this item?
parent_id = self.request.GET.get('parent', None)
if parent_id:
initials['part'] = get_object_or_404(Part, pk=parent_id)
return initials
class BomItemEdit(UpdateView):
model = BomItem
form_class = EditBomItemForm
template_name = 'part/bom-edit.html'
class BomItemDelete(DeleteView):
model = BomItem
template_name = 'part/bom-delete.html'
context_object_name = 'item'
success_url = '/part'
def post(self, request, *args, **kwargs):
if 'confirm' in request.POST:
return super(BomItemDelete, self).post(request, *args, **kwargs)
else:
return HttpResponseRedirect(self.get_object().get_absolute_url())

View File

@ -7,6 +7,7 @@
background-color: #777;
color: #fff;
border-radius: 5px;
margin-left: 10px;
}
.part-thumb {

View File

@ -2,6 +2,7 @@ from django.contrib import admin
from simple_history.admin import SimpleHistoryAdmin
from .models import StockLocation, StockItem
from .models import StockItemTracking
class LocationAdmin(admin.ModelAdmin):
@ -12,5 +13,10 @@ class StockItemAdmin(SimpleHistoryAdmin):
list_display = ('part', 'quantity', 'location', 'status', 'updated')
class StockTrackingAdmin(admin.ModelAdmin):
list_display = ('item', 'date', 'title')
admin.site.register(StockLocation, LocationAdmin)
admin.site.register(StockItem, StockItemAdmin)
admin.site.register(StockItemTracking, StockTrackingAdmin)

View File

@ -3,9 +3,6 @@ from django_filters import NumberFilter
from rest_framework import generics, permissions, response
# from InvenTree.models import FilterChildren
from .models import StockLocation, StockItem
from .serializers import StockItemSerializer, StockQuantitySerializer

View File

@ -14,7 +14,6 @@ class EditStockLocationForm(forms.ModelForm):
self.helper.form_id = 'id-edit-part-form'
self.helper.form_class = 'blueForms'
self.helper.form_method = 'post'
#self.helper.form_action = 'submit'
self.helper.add_input(Submit('submit', 'Submit'))
@ -36,16 +35,20 @@ class EditStockItemForm(forms.ModelForm):
self.helper.form_id = 'id-edit-part-form'
self.helper.form_class = 'blueForms'
self.helper.form_method = 'post'
#self.helper.form_action = 'submit'
self.helper.add_input(Submit('submit', 'Submit'))
class Meta:
model = StockItem
fields = [
'part',
'supplier_part',
'location',
'belongs_to',
'serial',
'batch',
'quantity',
]
'status',
'customer',
'URL',
]

View File

@ -0,0 +1,81 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2018-04-16 08:53
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('supplier', '0006_auto_20180415_1011'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('stock', '0006_auto_20180415_0302'),
]
operations = [
migrations.CreateModel(
name='StockItemTracking',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField(auto_now_add=True)),
('title', models.CharField(max_length=250)),
('description', models.CharField(blank=True, max_length=1024)),
],
),
migrations.RemoveField(
model_name='historicalstockitem',
name='history_user',
),
migrations.RemoveField(
model_name='historicalstockitem',
name='location',
),
migrations.RemoveField(
model_name='historicalstockitem',
name='part',
),
migrations.RemoveField(
model_name='historicalstockitem',
name='stocktake_user',
),
migrations.RemoveField(
model_name='historicalstockitem',
name='supplier_part',
),
migrations.AddField(
model_name='stockitem',
name='batch',
field=models.CharField(blank=True, max_length=100),
),
migrations.AddField(
model_name='stockitem',
name='belongs_to',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='owned_parts', to='stock.StockItem'),
),
migrations.AddField(
model_name='stockitem',
name='customer',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stockitems', to='supplier.Customer'),
),
migrations.AddField(
model_name='stockitem',
name='serial',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.DeleteModel(
name='HistoricalStockItem',
),
migrations.AddField(
model_name='stockitemtracking',
name='item',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tracking_info', to='stock.StockItem'),
),
migrations.AddField(
model_name='stockitemtracking',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL),
),
]

View File

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2018-04-16 11:05
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stock', '0007_auto_20180416_0853'),
]
operations = [
migrations.AddField(
model_name='stockitem',
name='URL',
field=models.URLField(blank=True, max_length=125),
),
]

View File

@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2018-04-16 12:53
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('part', '0019_auto_20180416_1249'),
('stock', '0008_stockitem_url'),
]
operations = [
migrations.AlterField(
model_name='stockitem',
name='batch',
field=models.CharField(blank=True, help_text='Batch code for this stock item', max_length=100),
),
migrations.AlterField(
model_name='stockitem',
name='belongs_to',
field=models.ForeignKey(blank=True, help_text='Is this item installed in another item?', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='owned_parts', to='stock.StockItem'),
),
migrations.AlterField(
model_name='stockitem',
name='customer',
field=models.ForeignKey(blank=True, help_text='Item assigned to customer?', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stockitems', to='supplier.Customer'),
),
migrations.AlterField(
model_name='stockitem',
name='location',
field=models.ForeignKey(blank=True, help_text='Where is this stock item located?', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='items', to='stock.StockLocation'),
),
migrations.AlterField(
model_name='stockitem',
name='serial',
field=models.PositiveIntegerField(blank=True, help_text='Serial number for this item', null=True),
),
migrations.AlterUniqueTogether(
name='stockitem',
unique_together=set([('part', 'serial')]),
),
]

View File

@ -3,9 +3,9 @@ from django.utils.translation import ugettext as _
from django.db import models, transaction
from django.core.validators import MinValueValidator
from django.contrib.auth.models import User
from simple_history.models import HistoricalRecords
from supplier.models import SupplierPart
from supplier.models import Customer
from part.models import Part
from InvenTree.models import InvenTreeTree
@ -14,16 +14,23 @@ from datetime import datetime
from django.db.models.signals import pre_delete
from django.dispatch import receiver
class StockLocation(InvenTreeTree):
""" Organization tree for StockItem objects
A "StockLocation" can be considered a warehouse, or storage location
Stock locations can be heirarchical as required
"""
def get_absolute_url(self):
return '/stock/location/{id}/'.format(id=self.id)
@property
def items(self):
stock_list = self.stockitem_set.all()
return stock_list
return self.stockitem_set.all()
@property
def has_items(self):
return self.items.count() > 0
@receiver(pre_delete, sender=StockLocation, dispatch_uid='stocklocation_delete_log')
@ -31,19 +38,72 @@ def before_delete_stock_location(sender, instance, using, **kwargs):
# Update each part in the stock location
for item in instance.items.all():
item.location = instance.parent
item.save()
# If this location has a parent, move the child stock items to the parent
if instance.parent:
item.location = instance.parent
item.save()
# No parent location? Delete the stock items
else:
item.delete()
# Update each child category
for child in instance.children.all():
child.parent = instance.parent
child.save()
class StockItem(models.Model):
"""
A 'StockItem' instance represents a quantity of physical instances of a part.
It may exist in a StockLocation, or as part of a sub-assembly installed into another StockItem
StockItems may be tracked using batch or serial numbers.
If a serial number is assigned, then StockItem cannot have a quantity other than 1
"""
def get_absolute_url(self):
return '/stock/item/{id}/'.format(id=self.id)
class Meta:
unique_together = [
('part', 'serial'),
]
# The 'master' copy of the part of which this stock item is an instance
part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='locations')
# The 'supplier part' used in this instance. May be null if no supplier parts are defined the master part
supplier_part = models.ForeignKey(SupplierPart, blank=True, null=True, on_delete=models.SET_NULL)
# Where the part is stored. If the part has been used to build another stock item, the location may not make sense
location = models.ForeignKey(StockLocation, on_delete=models.DO_NOTHING,
related_name='items', blank=True, null=True)
related_name='items', blank=True, null=True,
help_text='Where is this stock item located?')
# If this StockItem belongs to another StockItem (e.g. as part of a sub-assembly)
belongs_to = models.ForeignKey('self', on_delete=models.DO_NOTHING,
related_name='owned_parts', blank=True, null=True,
help_text='Is this item installed in another item?')
# The StockItem may be assigned to a particular customer
customer = models.ForeignKey(Customer, on_delete=models.SET_NULL,
related_name='stockitems', blank=True, null=True,
help_text='Item assigned to customer?')
# Optional serial number
serial = models.PositiveIntegerField(blank=True, null=True,
help_text='Serial number for this item')
# Optional URL to link to external resource
URL = models.URLField(max_length=125, blank=True)
# Optional batch information
batch = models.CharField(max_length=100, blank=True,
help_text='Batch code for this stock item')
# Quantity of this stock item. Value may be overridden by other settings
quantity = models.PositiveIntegerField(validators=[MinValueValidator(0)])
# Last time this item was updated (set automagically)
updated = models.DateField(auto_now=True)
# last time the stock was checked / counted
@ -78,8 +138,9 @@ class StockItem(models.Model):
infinite = models.BooleanField(default=False)
# History of this item
history = HistoricalRecords()
@property
def has_tracking_info(self):
return self.tracking_info.count() > 0
@transaction.atomic
def stocktake(self, count, user):
@ -128,3 +189,34 @@ class StockItem(models.Model):
n=self.quantity,
part=self.part.name,
loc=self.location.name)
@property
def is_trackable(self):
return self.part.trackable
class StockItemTracking(models.Model):
""" Stock tracking entry
"""
# Stock item
item = models.ForeignKey(StockItem, on_delete=models.CASCADE,
related_name='tracking_info')
# Date this entry was created (cannot be edited)
date = models.DateField(auto_now_add=True, editable=False)
# Short-form title for this tracking entry
title = models.CharField(max_length=250)
# Optional longer description
description = models.CharField(max_length=1024, blank=True)
# Which user created this tracking entry?
user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True)
# TODO
# image = models.ImageField(upload_to=func, max_length=255, null=True, blank=True)
# TODO
# file = models.FileField()

View File

@ -5,6 +5,7 @@
{% include "stock/loc_link.html" with location=None %}
{% if locations.all|length > 0 %}
<h4>Storage Locations</h4>
{% include "stock/location_list.html" with locations=locations %}
{% endif %}
@ -12,4 +13,10 @@
{% include "stock/stock_table.html" with items=items %}
{% endif %}
<div class='container-fluid'>
<a href="{% url 'stock-location-create' %}">
<button class="btn btn-success">New Stock Location</button>
</a>
</div>
{% endblock %}

View File

@ -4,19 +4,52 @@
{% include "stock/loc_link.html" with location=item.location %}
<h3>Stock entry details</h3>
<table class="table table-striped">
<tr>
<td>Part</td>
<td><a href="{% url 'part-detail' item.part.id %}">{{ item.part.name }}</td>
<td><a href="{% url 'part-stock' item.part.id %}">{{ item.part.name }}</td>
</tr>
{% if item.belongs_to %}
<tr>
<td>Belongs To</td>
<td><a href="{% url 'stock-item-detail' item.belongs_to.id %}">{{ item.belongs_to }}</a></td>
</tr>
{% elif item.location %}
<tr>
<td>Location</td>
<td><a href="{% url 'stock-location-detail' item.location.id %}">{{ item.location.name }}</a></td>
</tr>
{% endif %}
{% if item.serial %}
<tr>
<td>Serial</td>
<td>{{ item.serial }}</td>
</tr>
{% endif %}
{% if item.batch %}
<tr>
<td>Batch</td>
<td>{{ item.batch }}</td>
</tr>
{% endif %}
{% if item.customer %}
<tr>
<td>Customer</td>
<td>{{ item.customer.name }}</td>
</tr>
{% endif %}
<tr>
<td>Quantity</td>
<td>{{ item.quantity }}</td>
</tr>
{% if item.URL %}
<tr>
<td>URL</td>
<td><a href="{{ item.URL }}">{{ item.URL }}</a></td>
</tr>
{% endif %}
{% if item.supplier_part %}
<tr>
<td>Supplier Part</td>
@ -35,7 +68,7 @@
{% endif %}
<tr>
<td>Status</td>
<td>{{ item.status }}</td>
<td>{{ item.get_status_display }}</td>
</tr>
{% if item.notes %}
<tr>
@ -45,4 +78,28 @@
{% endif %}
</table>
{% if item.has_tracking_info %}
<h3>Stock Tracking</h3>
<ul class='list-group'>
{% for track in item.tracking_info.all %}
<li class='list-group-item'>
<b>{{ track.title }}</b>
{% if track.description %}
<br><br>{{ track.description }}</i>
{% endif %}
<span class='badge'>{{ track.date }}</span>
</li>
{% endfor %}
</ul>
{% endif %}
<div class='container-fluid'>
<a href="{% url 'stock-item-edit' item.id %}">
<button class='btn btn-info'>Edit Stock Item</button>
</a>
<a href="{% url 'stock-item-delete' item.id %}">
<button class='btn btn-danger'>Delete Stock Item</button>
</a>
</div>
{% endblock %}

View File

@ -0,0 +1,5 @@
{% extends "create_edit_obj.html" %}
{% block obj_title %}
Create a new stock item
{% endblock %}

View File

@ -0,0 +1,9 @@
{% extends "delete_obj.html" %}
{% block del_title %}
Are you sure you want to delete this stock item?
{% endblock %}
{% block del_body %}
This will remove <b>{{ item.quantity }}</b> units of <b>'{{ item.part.name }}'</b> from stock.
{% endblock %}

View File

@ -0,0 +1,5 @@
{% extends "create_edit_obj.html" %}
{% block obj_title %}
Edit stock item for part '{{ item.part.name }}'
{% endblock %}

View File

@ -1,7 +1,7 @@
<div class="navigation">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item{% if location is None %} active" aria-current="page{% endif %}"><a href="/stock/">Parts</a></li>
<li class="breadcrumb-item{% if location is None %} active" aria-current="page{% endif %}"><a href="/stock/">Stock</a></li>
{% if location %}
{% for path_item in location.parentpath %}
<li class='breadcrumb-item'><a href="{% url 'stock-location-detail' path_item.id %}">{{ path_item.name }}</a></li>

View File

@ -4,14 +4,35 @@
{% include "stock/loc_link.html" with location=location %}
<p>
<b>{{ location.name }}</b><br>
<i>{{ location.description }}</i>
</p>
<h3>{{ location.name }}</h3>
<p>{{ location.description }}</p>
{% if location.has_children %}
<h4>Sub Locations</h4>
{% include "stock/location_list.html" with locations=location.children %}
{% endif %}
{% if location.has_items %}
<h4>Stock Items</h4>
{% include "stock/stock_table.html" with items=location.items %}
{% endif %}
<div class='container-fluid'>
<a href="{% url 'stock-location-create' %}?location={{ location.id }}">
<button class='btn btn-success'>New Stock Location</button>
</a>
<a href="{% url 'stock-item-create' %}?location={{ location.id }}">
<button class='btn btn-success'>New Stock Item</button>
</a>
<a href="{% url 'stock-location-edit' location.id %}">
<button class="btn btn-info">Edit Location</button>
</a>
<a href="{% url 'stock-location-delete' location.id %}">
<button class="btn btn-danger">Delete Location</button>
</a>
</div>
{% endblock %}

View File

@ -0,0 +1,5 @@
{% extends "create_edit_obj.html" %}
{% block obj_title %}
Create a new stock location
{% endblock %}

View File

@ -0,0 +1,41 @@
{% extends 'delete_obj.html' %}
{% block del_title %}
Are you sure you want to delete stock location '{{ location.name }}'?
{% endblock %}
{% block del_body %}
{% if location.children.all|length > 0 %}
<p>This location contains {{ location.children.all|length }} child locations.<br>
If this location is deleted, these child locations will be moved to
{% if location.parent %}
the '{{ location.parent.name }}' location.
{% else %}
the top level 'Stock' category.
{% endif %}
</p>
<ul class='list-group'>
{% for loc in location.children.all %}
<li class='list-group-item'><b>{{ loc.name }}</b> - <i>{{ loc.description}}</i></li>
{% endfor %}
</ul>
{% endif %}
{% if location.items.all|length > 0 %}
<p>This location contains {{ location.items.all|length }} stock items.<br>
{% if location.parent %}
If this location is deleted, these items will be moved to the '{{ location.parent.name }}' location.
{% else %}
If this location is deleted, these items will be deleted!
{% endif %}
</p>
<ul class='list-group'>
{% for item in location.items.all %}
<li class='list-group-item'><b>{{ item.part.name }}</b> - <i>{{ item.part.description }}</i><span class='badge'>{{ item.quantity }}</span></li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,5 @@
{% extends "create_edit_obj.html" %}
{% block obj_title %}
Edit stock location '{{ location.name }}'
{% endblock %}

View File

@ -1,4 +1,3 @@
Storage locations:
<ul class="list-group">
{% for child in locations.all %}
<li class="list-group-item"><a href="{% url 'stock-location-detail' child.id %}">{{ child.name }}</a></li>

View File

@ -8,7 +8,7 @@
</tr>
{% for item in items.all %}
<tr>
<td><a href="{% url 'part-detail' item.part.id %}">{{ item.part.name }}</a></td>
<td><a href="{% url 'part-stock' item.part.id %}">{{ item.part.name }}</a></td>
<td>{{ item.quantity }}</td>
<td>{{ item.status }}</td>
<td>{{ item.stocktake_date }}</td>

View File

@ -1,5 +1,4 @@
from django.conf.urls import url, include
from django.views.generic.base import RedirectView
from . import views
from . import api
@ -52,10 +51,10 @@ stock_urls = [
url(r'^location/new/', views.StockLocationCreate.as_view(), name='stock-location-create'),
url(r'^item/new/?', views.StockItemCreate.as_view(), name='stock-item-create'),
# Individual stock items
url(r'^item/(?P<pk>\d+)/', include(stock_item_detail_urls)),
url(r'^item/new/', views.StockItemCreate.as_view(), name='stock-item-create'),
url(r'^.*$', views.StockIndex.as_view(), name='stock-index'),
]
]

View File

@ -1,16 +1,16 @@
from django.shortcuts import get_object_or_404, render
from django.shortcuts import get_object_or_404
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.views.generic import DetailView, ListView
from django.views.generic.edit import UpdateView, DeleteView, CreateView
from part.models import Part
from .models import StockItem, StockLocation
from .forms import EditStockLocationForm
from .forms import EditStockItemForm
class StockIndex(ListView):
model = StockItem
template_name = 'stock/index.html'
@ -29,6 +29,7 @@ class StockIndex(ListView):
return context
class StockLocationDetail(DetailView):
context_object_name = 'location'
template_name = 'stock/location.html'
@ -46,35 +47,60 @@ class StockItemDetail(DetailView):
class StockLocationEdit(UpdateView):
model = StockLocation
form_class = EditStockLocationForm
template_name = '/stock/location-edit.html'
template_name = 'stock/location_edit.html'
context_object_name = 'location'
class StockItemEdit(UpdateView):
model = StockItem
form_class = EditStockItemForm
template_name = '/stock/item-edit.html'
template_name = 'stock/item_edit.html'
context_object_name = 'item'
class StockLocationCreate(CreateView):
model = StockLocation
form_class = EditStockLocationForm
template_name = '/stock/location-create.html'
template_name = 'stock/location_create.html'
context_object_name = 'location'
def get_initial(self):
initials = super(StockLocationCreate, self).get_initial().copy()
loc_id = self.request.GET.get('location', None)
if loc_id:
initials['parent'] = get_object_or_404(StockLocation, pk=loc_id)
return initials
class StockItemCreate(CreateView):
model = StockItem
form_class = EditStockItemForm
template_name = '/stock/item-create.html'
template_name = 'stock/item_create.html'
context_object_name = 'item'
def get_initial(self):
initials = super(StockItemCreate, self).get_initial().copy()
part_id = self.request.GET.get('part', None)
loc_id = self.request.GET.get('location', None)
if part_id:
initials['part'] = get_object_or_404(Part, pk=part_id)
if loc_id:
initials['location'] = get_object_or_404(StockLocation, pk=loc_id)
return initials
class StockLocationDelete(DeleteView):
model = StockLocation
success_url = '/stock/'
template_name = '/stock/location-delete.html'
success_url = '/stock'
template_name = 'stock/location_delete.html'
context_object_name = 'location'
def post(self, request, *args, **kwargs):
if 'confirm' in request.POST:
@ -84,12 +110,13 @@ class StockLocationDelete(DeleteView):
class StockItemDelete(DeleteView):
model = StockLocation
model = StockItem
success_url = '/stock/'
template_name = '/stock/item-delete.html'
template_name = 'stock/item_delete.html'
context_object_name = 'item'
def post(self, request, *args, **kwargs):
if 'confirm' in request.POST:
return super(StockItemDelete, self).post(request, *args, **kwargs)
else:
return HttpResponseRedirect(self.get_object().get_absolute_url())
return HttpResponseRedirect(self.get_object().get_absolute_url())

View File

@ -1,13 +1,18 @@
from django.contrib import admin
from import_export.admin import ImportExportModelAdmin
from .models import Supplier, SupplierPart, Customer, Manufacturer
class CompanyAdmin(admin.ModelAdmin):
class CompanyAdmin(ImportExportModelAdmin):
list_display = ('name', 'website', 'contact')
class SupplierPartAdmin(ImportExportModelAdmin):
list_display = ('part', 'supplier', 'SKU')
admin.site.register(Customer, CompanyAdmin)
admin.site.register(Supplier, CompanyAdmin)
admin.site.register(Manufacturer, CompanyAdmin)
admin.site.register(SupplierPart)
admin.site.register(SupplierPart, SupplierPartAdmin)

View File

@ -14,7 +14,6 @@ class EditSupplierForm(forms.ModelForm):
self.helper.form_id = 'id-edit-part-form'
self.helper.form_class = 'blueForms'
self.helper.form_method = 'post'
#self.helper.form_action = 'submit'
self.helper.add_input(Submit('submit', 'Submit'))
@ -40,7 +39,6 @@ class EditSupplierPartForm(forms.ModelForm):
self.helper.form_id = 'id-edit-part-form'
self.helper.form_class = 'blueForms'
self.helper.form_method = 'post'
#self.helper.form_action = 'submit'
self.helper.add_input(Submit('submit', 'Submit'))
@ -54,4 +52,4 @@ class EditSupplierPartForm(forms.ModelForm):
'URL',
'manufacturer',
'MPN',
]
]

View File

@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2018-04-16 12:53
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('supplier', '0006_auto_20180415_1011'),
]
operations = [
migrations.AlterField(
model_name='supplierpart',
name='MPN',
field=models.CharField(blank=True, help_text='Manufacturer part number', max_length=100),
),
migrations.AlterField(
model_name='supplierpart',
name='SKU',
field=models.CharField(help_text='Supplier stock keeping unit', max_length=100),
),
migrations.AlterField(
model_name='supplierpart',
name='manufacturer',
field=models.ForeignKey(blank=True, help_text='Manufacturer', null=True, on_delete=django.db.models.deletion.SET_NULL, to='supplier.Manufacturer'),
),
migrations.AlterField(
model_name='supplierpart',
name='part',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='supplier_parts', to='part.Part'),
),
]

View File

@ -14,6 +14,14 @@ class Supplier(Company):
def get_absolute_url(self):
return "/supplier/{id}/".format(id=self.id)
@property
def part_count(self):
return self.parts.count()
@property
def has_parts(self):
return self.part_count > 0
class Manufacturer(Company):
""" Represents a manfufacturer
@ -42,19 +50,20 @@ class SupplierPart(models.Model):
# Link to an actual part
# The part will have a field 'supplier_parts' which links to the supplier part options
part = models.ForeignKey(Part, null=True, blank=True, on_delete=models.SET_NULL,
part = models.ForeignKey(Part, on_delete=models.CASCADE,
related_name='supplier_parts')
supplier = models.ForeignKey(Supplier, on_delete=models.CASCADE,
related_name = 'parts')
related_name='parts')
SKU = models.CharField(max_length=100, help_text='Supplier stock keeping unit')
SKU = models.CharField(max_length=100)
manufacturer = models.ForeignKey(Manufacturer, blank=True, null=True, on_delete=models.SET_NULL, help_text='Manufacturer')
manufacturer = models.ForeignKey(Manufacturer, blank=True, null=True, on_delete=models.SET_NULL)
MPN = models.CharField(max_length=100, blank=True)
MPN = models.CharField(max_length=100, blank=True, help_text='Manufacturer part number')
URL = models.URLField(blank=True)
description = models.CharField(max_length=250, blank=True)
# Default price for a single unit

View File

@ -5,12 +5,12 @@ Are you sure you want to delete supplier '{{ supplier.name }}'?
{% endblock %}
{% block del_body %}
{% if supplier.parts.all|length > 0 %}
<p>There are {{ supplier.parts.all|length }} parts sourced from this supplier.<br>
If this supplier is deleted, these child categories will also be deleted.</p>
{% if supplier.part_count > 0 %}
<p>There are {{ supplier.part_count }} parts sourced from this supplier.<br>
If this supplier is deleted, these supplier part entries will also be deleted.</p>
<ul class='list-group'>
{% for part in supplier.parts.all %}
<li class='list-group-item'><b>{{ part.SKU }}</b><i>Part - {{ part.part.name }}</i></li>
<li class='list-group-item'><b>{{ part.SKU }}</b><br><i>Part - {{ part.part.name }}</i></li>
{% endfor %}
</ul>
{% endif %}

View File

@ -6,12 +6,7 @@
<div class="col-sm-6">
<h3>{{ supplier.name }}</h3>
<p>{{ supplier.description }}</p>
<p><a href="{% url 'supplier-edit' supplier.id %}">
<button class="btn btn-info">Edit supplier details</button>
</a></p>
<p><a href="{% url 'supplier-delete' supplier.id %}">
<button class="btn btn-danger">Delete supplier</button>
</a></p>
<p>{{ supplier.notes }}</p>
</div>
<div class="col-sm-6">
<table class="table">
@ -48,21 +43,24 @@
<table class="table table-striped">
<tr>
<th>SKU</th>
<th>Part</th>
<th>Manufacturer</th>
<th>Description</th>
<th>Parent Part</th>
<th>MPN</th>
<th>URL</th>
</tr>
{% for part in supplier.parts.all %}
<tr>
<td><a href="{% url 'supplier-part-detail' part.id %}">{{ part.SKU }}</a></td>
<td>{{ part.description }}</td>
<td>
{% if part.part %}
<a href="{% url 'part-detail' part.part.id %}">{{ part.part.name }}</a>
<a href="{% url 'part-suppliers' part.part.id %}">{{ part.part.name }}</a>
{% endif %}
</td>
<td>Manufacturer name goes here</td>
<td>MPN goes here</td>
<td>
{% if part.manufacturer %}{{ part.manufacturer.name }}{% endif %}
{% if part.MPN %} | {{ part.MPN }}{% endif %}
</td>
<td>{{ part.URL }}</td>
{% endfor %}
</table>
@ -71,5 +69,12 @@
<a href="{% url 'supplier-part-create' %}?supplier={{ supplier.id }}">
<button class="btn btn-success">New Supplier Part</button>
</a>
<a href="{% url 'supplier-edit' supplier.id %}">
<button class="btn btn-info">Edit supplier details</button>
</a>
<a href="{% url 'supplier-delete' supplier.id %}">
<button class="btn btn-danger">Delete supplier</button>
</a>
</div>
{% endblock %}

View File

@ -8,10 +8,10 @@
<tr><td>SKU</td><td>{{ part.SKU }}</tr></tr>
<tr><td>Supplier</td><td><a href="{% url 'supplier-detail' part.supplier.id %}">{{ part.supplier.name }}</a></td></tr>
<tr>
<td>Part</td>
<td>Parent Part</td>
<td>
{% if part.part %}
<a href="{% url 'part-detail' part.part.id %}">{{ part.part.name }}</a>
<a href="{% url 'part-suppliers' part.part.id %}">{{ part.part.name }}</a>
{% endif %}
</td>
</tr>
@ -22,19 +22,21 @@
<tr><td>Description</td><td>{{ part.description }}</td></tr>
{% endif %}
{% if part.manufacturer %}
<tr><td>Manufacturer</td><td>TODO</td></tr>
<tr><td>MPN</td><td>TODO</td></tr>
<tr><td>Manufacturer</td><td>{% if part.manufacturer %}{{ part.manufacturer.name }}{% endif %}</td></tr>
<tr><td>MPN</td><td>{{ part.MPN }}</td></tr>
{% endif %}
</table>
<br>
<p><a href="{% url 'supplier-part-edit' part.id %}">
<button class="btn btn-info">Edit supplier details</button>
</a></p>
<p><a href="{% url 'supplier-part-delete' part.id %}">
<div class='container-fluid'>
<a href="{% url 'supplier-part-edit' part.id %}">
<button class="btn btn-info">Edit supplier part</button>
</a>
<a href="{% url 'supplier-part-delete' part.id %}">
<button class="btn btn-danger">Delete supplier part</button>
</a></p>
</a>
</div>
{% endblock %}

View File

@ -2,7 +2,6 @@ from django.conf.urls import url, include
from django.views.generic.base import RedirectView
from . import views
from . import api
"""
cust_urls = [
@ -75,4 +74,4 @@ supplier_urls = [
# Redirect any other patterns
url(r'^.*$', RedirectView.as_view(url='', permanent=False), name='supplier-index'),
]
]

View File

@ -1,15 +1,16 @@
from django.shortcuts import get_object_or_404, render
from django.shortcuts import get_object_or_404
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.views.generic import DetailView, ListView
from django.views.generic.edit import UpdateView, DeleteView, CreateView
from part.models import Part
from .models import Supplier, SupplierPart
from .forms import EditSupplierForm
from .forms import EditSupplierPartForm
class SupplierIndex(ListView):
model = Supplier
template_name = 'supplier/index.html'
@ -76,9 +77,16 @@ class SupplierPartCreate(CreateView):
initials = super(SupplierPartCreate, self).get_initial().copy()
supplier_id = self.request.GET.get('supplier', None)
part_id = self.request.GET.get('part', None)
if supplier_id:
initials['supplier'] = get_object_or_404(Supplier, pk=supplier_id)
# TODO
# self.fields['supplier'].disabled = True
if part_id:
initials['part'] = get_object_or_404(Part, pk=part_id)
# TODO
# self.fields['part'].disabled = True
return initials

View File

@ -1,16 +0,0 @@
from django.contrib import admin
from .models import UniquePart, PartTrackingInfo
class UniquePartAdmin(admin.ModelAdmin):
list_display = ('part', 'serial', 'status', 'creation_date')
class PartTrackingAdmin(admin.ModelAdmin):
list_display = ('part', 'date', 'title')
admin.site.register(UniquePart, UniquePartAdmin)
admin.site.register(PartTrackingInfo, PartTrackingAdmin)

View File

@ -1,97 +0,0 @@
from django_filters.rest_framework import FilterSet, DjangoFilterBackend
from django_filters import NumberFilter
from rest_framework import generics, permissions
from .models import UniquePart, PartTrackingInfo
from .serializers import UniquePartSerializer, PartTrackingInfoSerializer
class UniquePartDetail(generics.RetrieveUpdateDestroyAPIView):
"""
get:
Return a single UniquePart
post:
Update a UniquePart
delete:
Remove a UniquePart
"""
queryset = UniquePart.objects.all()
serializer_class = UniquePartSerializer
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
class UniquePartFilter(FilterSet):
# Filter based on serial number
min_sn = NumberFilter(name='serial', lookup_expr='gte')
max_sn = NumberFilter(name='serial', lookup_expr='lte')
class Meta:
model = UniquePart
fields = ['serial', 'part', 'customer']
class UniquePartList(generics.ListCreateAPIView):
"""
get:
Return a list of all UniqueParts
(with optional query filter)
post:
Create a new UniquePart
"""
queryset = UniquePart.objects.all()
serializer_class = UniquePartSerializer
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
filter_backends = (DjangoFilterBackend,)
filter_class = UniquePartFilter
class PartTrackingDetail(generics.RetrieveUpdateDestroyAPIView):
"""
get:
Return a single PartTrackingInfo object
post:
Update a PartTrackingInfo object
delete:
Remove a PartTrackingInfo object
"""
queryset = PartTrackingInfo.objects.all()
serializer_class = PartTrackingInfoSerializer
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
class PartTrackingFilter(FilterSet):
class Meta:
model = PartTrackingInfo
fields = ['part']
class PartTrackingList(generics.ListCreateAPIView):
"""
get:
Return a list of all PartTrackingInfo objects
(with optional query filter)
post:
Create a new PartTrackingInfo object
"""
queryset = PartTrackingInfo.objects.all()
serializer_class = PartTrackingInfoSerializer
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
filter_backends = (DjangoFilterBackend,)
filter_class = PartTrackingFilter

View File

@ -1,7 +0,0 @@
from __future__ import unicode_literals
from django.apps import AppConfig
class TrackConfig(AppConfig):
name = 'track'

View File

@ -1,47 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2018-04-12 05:02
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('part', '0001_initial'),
('supplier', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='PartTrackingInfo',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField(auto_now_add=True)),
('notes', models.CharField(max_length=500)),
],
),
migrations.CreateModel(
name='UniquePart',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('creation_date', models.DateField(auto_now_add=True)),
('serial', models.IntegerField()),
('status', models.IntegerField(choices=[(0, 'In progress'), (40, 'Damaged'), (10, 'In stock'), (50, 'Destroyed'), (20, 'Shipped'), (30, 'Returned')], default=0)),
('customer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='supplier.Customer')),
('part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='part.Part')),
],
),
migrations.AddField(
model_name='parttrackinginfo',
name='part',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tracking_info', to='track.UniquePart'),
),
migrations.AlterUniqueTogether(
name='uniquepart',
unique_together=set([('part', 'serial')]),
),
]

View File

@ -1,26 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2018-04-13 14:40
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('track', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='uniquepart',
name='part',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='serials', to='part.Part'),
),
migrations.AlterField(
model_name='uniquepart',
name='serial',
field=models.PositiveIntegerField(),
),
]

View File

@ -1,31 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2018-04-15 01:47
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('track', '0002_auto_20180413_1440'),
]
operations = [
migrations.AddField(
model_name='parttrackinginfo',
name='title',
field=models.CharField(default='tracking information', max_length=250),
preserve_default=False,
),
migrations.AlterField(
model_name='parttrackinginfo',
name='notes',
field=models.CharField(blank=True, max_length=1024),
),
migrations.AlterField(
model_name='uniquepart',
name='status',
field=models.IntegerField(choices=[(0, 'In progress'), (35, 'Repaired'), (40, 'Damaged'), (10, 'In stock'), (50, 'Destroyed'), (20, 'Shipped'), (30, 'Returned')], default=0),
),
]

View File

@ -1,23 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2018-04-15 01:50
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('track', '0003_auto_20180415_0147'),
]
operations = [
migrations.AddField(
model_name='parttrackinginfo',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL),
),
]

View File

@ -1,73 +0,0 @@
from __future__ import unicode_literals
from rest_framework.exceptions import ValidationError
from django.utils.translation import ugettext as _
from django.db import models
from django.contrib.auth.models import User
from supplier.models import Customer
from part.models import Part
class UniquePart(models.Model):
""" A unique instance of a Part object.
Used for tracking parts based on serial numbers,
and tracking all events in the life of a part
"""
class Meta:
# Cannot have multiple parts with same serial number
unique_together = ('part', 'serial')
part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='serials')
creation_date = models.DateField(auto_now_add=True,
editable=False)
serial = models.PositiveIntegerField()
# createdBy = models.ForeignKey(User)
customer = models.ForeignKey(Customer, blank=True, null=True)
# Part status types
PART_IN_PROGRESS = 0
PART_IN_STOCK = 10
PART_SHIPPED = 20
PART_RETURNED = 30
PART_REPAIRED = 35
PART_DAMAGED = 40
PART_DESTROYED = 50
PART_STATUS_CODES = {
PART_IN_PROGRESS: _("In progress"),
PART_IN_STOCK: _("In stock"),
PART_SHIPPED: _("Shipped"),
PART_RETURNED: _("Returned"),
PART_REPAIRED: _("Repaired"),
PART_DAMAGED: _("Damaged"),
PART_DESTROYED: _("Destroyed")
}
status = models.IntegerField(default=PART_IN_PROGRESS, choices=PART_STATUS_CODES.items())
def __str__(self):
return "{pn} - # {sn}".format(pn = self.part.name,
sn = self.serial)
class PartTrackingInfo(models.Model):
""" Single data-point in the life of a UniquePart
Each time something happens to the UniquePart,
a new PartTrackingInfo object should be created.
"""
part = models.ForeignKey(UniquePart, on_delete=models.CASCADE, related_name='tracking_info')
date = models.DateField(auto_now_add=True, editable=False)
title = models.CharField(max_length=250)
notes = models.CharField(max_length=1024, blank=True)
user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True)

View File

@ -1,23 +0,0 @@
from rest_framework import serializers
from .models import UniquePart, PartTrackingInfo
class UniquePartSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = UniquePart
fields = ['url',
'part',
'creation_date',
'serial',
# 'createdBy',
'customer',
'status']
class PartTrackingInfoSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = PartTrackingInfo
fields = '__all__'

View File

@ -1,26 +0,0 @@
{% extends "base.html" %}
{% block content %}
Part: <a href="{% url 'part-detail' part.part.id %}">{{ part.part.name }}</a><br>
Serial number: {{ part.serial }}
{% if part.tracking_info.all|length > 0 %}
<p>Tracking information:</p>
<ul class='list-group'>
{% for info in part.tracking_info.all %}
<li class='list-group-item'>
<div class='panel panel-default'>
<div class='panel-heading'>
{{ info.title }}<span class="badge">{{ info.date }}</span>
</div>
{% if info.notes %}
<div class='panel-body'>{{ info.notes }}</div>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}

View File

@ -1,5 +0,0 @@
{% extends "base.html"% }
{% block content %}
{% endblock %}

View File

@ -1,33 +0,0 @@
{% extends "base.html" %}
{% block content %}
<h3>Part Tracking</h3>
<ul class='list-group'>
{% for part in parts.all %}
<li class='list-group-item'>
<a href="{% url 'track-detail' part.id %}">
{{ part.part.name }} - SN {{ part.serial }}
</a>
</li>
{% endfor %}
</ul>
{% if is_paginated %}
<div class="pagination">
<span class="page-links">
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}">previous</a>
{% endif %}
<span class="page-current">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}.
</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}">next</a>
{% endif %}
</span>
</div>
{% endif %}
{% endblock %}

View File

@ -1,5 +0,0 @@
{% extends "base.html" %}
{% block content %}
{% endblock %}

View File

@ -1,3 +0,0 @@
# from django.test import TestCase
# Create your tests here.

View File

@ -1,38 +0,0 @@
from django.conf.urls import url, include
from django.views.generic.base import RedirectView
from . import views
"""
TODO - Implement JSON API for part serial number tracking
part_track_api_urls = [
url(r'^(?P<pk>[0-9]+)/?$', api.PartTrackingDetail.as_view(), name='parttrackinginfo-detail'),
url(r'^\?.*/?$', api.PartTrackingList.as_view()),
url(r'^$', api.PartTrackingList.as_view())
]
unique_api_urls = [
# Detail for a single unique part
url(r'^(?P<pk>[0-9]+)/?$', api.UniquePartDetail.as_view(), name='uniquepart-detail'),
# List all unique parts, with optional filters
url(r'^\?.*/?$', api.UniquePartList.as_view()),
url(r'^$', api.UniquePartList.as_view()),
]
"""
track_detail_urls = [
url('^.*$', views.TrackDetail.as_view(), name='track-detail'),
]
tracking_urls = [
# Detail view
url(r'^(?P<pk>\d+)/', include(track_detail_urls)),
# List ALL tracked items
url('', views.TrackIndex.as_view(), name='track-index'),
url(r'^.*$', RedirectView.as_view(url='', permanent=False), name='track-index'),
]

View File

@ -1,25 +0,0 @@
from django.shortcuts import get_object_or_404, render
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.views.generic import DetailView, ListView
from django.views.generic.edit import UpdateView, DeleteView, CreateView
from .models import UniquePart, PartTrackingInfo
class TrackIndex(ListView):
model = UniquePart
template_name = 'track/index.html'
context_object_name = 'parts'
paginate_by = 50
def get_queryset(self):
return UniquePart.objects.order_by('part__name', 'serial')
class TrackDetail(DetailView):
queryset = UniquePart.objects.all()
template_name = 'track/detail.html'
context_object_name='part'

View File

@ -18,7 +18,6 @@ migrate:
python InvenTree/manage.py makemigrations part
python InvenTree/manage.py makemigrations stock
python InvenTree/manage.py makemigrations supplier
python InvenTree/manage.py makemigrations track
python InvenTree/manage.py migrate --run-syncdb
python InvenTree/manage.py check

View File

@ -1,7 +1,9 @@
Django==1.11
pillow==3.1.2
djangorestframework==3.6.2
django_filter==1.0.2
django-simple-history==1.8.2
coreapi==2.3.0
pygments==2.2.0
django-crispy-forms==1.7.2
django-crispy-forms==1.7.2
django-import-export==1.0.0