This commit is contained in:
James Newlands 2018-04-17 18:03:44 +10:00
commit 960f697d02
30 changed files with 271 additions and 486 deletions

2
.gitignore vendored
View File

@ -6,7 +6,7 @@ __pycache__/
# Distribution / packaging
.Python
env/
build/
./build/
develop-eggs/
dist/
downloads/

View File

@ -0,0 +1,18 @@
import inspect
from enum import Enum
class ChoiceEnum(Enum):
""" Helper class to provide enumerated choice values for integer fields
"""
# http://blog.richard.do/index.php/2014/02/how-to-use-enums-for-django-field-choices/
@classmethod
def choices(cls):
# get all members of the class
members = inspect.getmembers(cls, lambda m: not(inspect.isroutine(m)))
# filter down to just properties
props = [m for m in members if not(m[0][:2] == '__')]
# format into django choice tuple
choices = tuple([(str(p[1].value), p[0]) for p in props])
return choices

View File

@ -50,6 +50,7 @@ INSTALLED_APPS = [
'part.apps.PartConfig',
'stock.apps.StockConfig',
'supplier.apps.SupplierConfig',
'build.apps.BuildConfig',
]
MIDDLEWARE = [

View File

@ -11,6 +11,8 @@ from stock.urls import stock_urls
# from supplier.urls import supplier_api_urls, supplier_api_part_urls
from supplier.urls import supplier_urls
from build.urls import build_urls
from django.conf import settings
from django.conf.urls.static import static
@ -68,6 +70,7 @@ urlpatterns = [
url(r'^part/', include(part_urls)),
url(r'^stock/', include(stock_urls)),
url(r'^supplier/', include(supplier_urls)),
url(r'^build/', include(build_urls)),
url(r'^admin/', admin.site.urls),
url(r'^auth/', include('rest_framework.urls', namespace='rest_framework')),

14
InvenTree/build/admin.py Normal file
View File

@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.contrib import admin
from .models import Build
class BuildAdmin(admin.ModelAdmin):
list_display = ('status', )
admin.site.register(Build, BuildAdmin)

View File

@ -1,7 +1,8 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.apps import AppConfig
class ProjectConfig(AppConfig):
name = 'project'
class BuildConfig(AppConfig):
name = 'build'

View File

@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2018-04-16 14:03
from __future__ import unicode_literals
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('part', '0019_auto_20180416_1249'),
]
operations = [
migrations.CreateModel(
name='Build',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.PositiveIntegerField(choices=[(b'20', b'Allocated'), (b'30', b'Cancelled'), (b'40', b'Complete'), (b'10', b'Pending')], default=10)),
],
),
migrations.CreateModel(
name='BuildOutput',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('part_name', models.CharField(max_length=255)),
('quantity', models.PositiveIntegerField(default=1, help_text='Number of parts to build', validators=[django.core.validators.MinValueValidator(1)])),
('build', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='outputs', to='build.Build')),
('part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='builds', to='part.Part')),
],
),
migrations.AlterUniqueTogether(
name='buildoutput',
unique_together=set([('part', 'build')]),
),
]

View File

@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2018-04-16 14:23
from __future__ import unicode_literals
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('build', '0001_initial'),
]
operations = [
migrations.AlterUniqueTogether(
name='buildoutput',
unique_together=set([]),
),
migrations.RemoveField(
model_name='buildoutput',
name='build',
),
migrations.RemoveField(
model_name='buildoutput',
name='part',
),
migrations.AddField(
model_name='build',
name='quantity',
field=models.PositiveIntegerField(default=1, help_text='Number of parts to build', validators=[django.core.validators.MinValueValidator(1)]),
),
migrations.DeleteModel(
name='BuildOutput',
),
]

View File

@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2018-04-16 14:28
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'),
('build', '0002_auto_20180416_1423'),
]
operations = [
migrations.AddField(
model_name='build',
name='part',
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='builds', to='part.Part'),
preserve_default=False,
),
]

39
InvenTree/build/models.py Normal file
View File

@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models
from django.core.validators import MinValueValidator
from InvenTree.helpers import ChoiceEnum
from part.models import Part
class Build(models.Model):
""" A Build object organises the creation of new parts from the component parts
It uses the part BOM to generate new parts.
Parts are then taken from stock
"""
class BUILD_STATUS(ChoiceEnum):
# The build is 'pending' - no action taken yet
Pending = 10
# The parts required for this build have been allocated
Allocated = 20
# The build has been cancelled (parts unallocated)
Cancelled = 30
# The build is complete!
Complete = 40
# Status of the build
status = models.PositiveIntegerField(default=BUILD_STATUS.Pending.value,
choices=BUILD_STATUS.choices())
part = models.ForeignKey(Part, on_delete=models.CASCADE,
related_name='builds')
quantity = models.PositiveIntegerField(default=1,
validators=[MinValueValidator(1)],
help_text='Number of parts to build')

View File

@ -0,0 +1,3 @@
{% include "base.html" %}
Build detail for Build No. {{ build.id }}.

View File

@ -0,0 +1,7 @@
{% extends "base.html" %}
{% block content %}
<h3>Builds</h3>
{% endblock %}

6
InvenTree/build/tests.py Normal file
View File

@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
# from django.test import TestCase
# Create your tests here.

17
InvenTree/build/urls.py Normal file
View File

@ -0,0 +1,17 @@
from django.conf.urls import url, include
from django.views.generic.base import RedirectView
from . import views
build_detail_urls = [
url(r'^.*$', views.BuildDetail.as_view(), name='build-detail'),
]
build_urls = [
# url(r'new/?', views.BuildCreate.as_view(), name='build-create'),
url(r'^(?P<pk>\d+)/', include(build_detail_urls)),
url(r'.*$', views.BuildIndex.as_view(), name='build-index'),
]

22
InvenTree/build/views.py Normal file
View File

@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.shortcuts import get_object_or_404
from django.http import HttpResponseRedirect
from django.views.generic import DetailView, ListView
from django.views.generic.edit import UpdateView, DeleteView, CreateView
from .models import Build
class BuildIndex(ListView):
model = Build
template_name = 'build/index.html'
context_object_name = 'builds'
class BuildDetail(DetailView):
model = Build
template_name = 'build/detail.html'
context_object_name = 'build'

View File

@ -85,9 +85,7 @@ part_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'^.*$', views.PartIndex.as_view(), name='part-index'),
]
"""

View File

@ -1,3 +1,5 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.shortcuts import get_object_or_404
from django.http import HttpResponseRedirect

View File

@ -1,25 +0,0 @@
from django.contrib import admin
from .models import ProjectCategory, Project, ProjectPart, ProjectRun
class ProjectCategoryAdmin(admin.ModelAdmin):
list_display = ('name', 'pathstring', 'description')
class ProjectAdmin(admin.ModelAdmin):
list_display = ('name', 'description', 'category')
class ProjectPartAdmin(admin.ModelAdmin):
list_display = ('part', 'project', 'quantity', 'output')
class ProjectRunAdmin(admin.ModelAdmin):
list_display = ('project', 'quantity', 'run_date')
admin.site.register(ProjectCategory, ProjectCategoryAdmin)
admin.site.register(Project, ProjectAdmin)
admin.site.register(ProjectPart, ProjectPartAdmin)
admin.site.register(ProjectRun, ProjectRunAdmin)

View File

@ -1,72 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2018-04-12 05:02
from __future__ import unicode_literals
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('part', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Project',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('description', models.CharField(blank=True, max_length=500)),
],
),
migrations.CreateModel(
name='ProjectCategory',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('description', models.CharField(blank=True, max_length=250)),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='project.ProjectCategory')),
],
options={
'verbose_name': 'Project Category',
'verbose_name_plural': 'Project Categories',
},
),
migrations.CreateModel(
name='ProjectPart',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.PositiveIntegerField(default=1, validators=[django.core.validators.MinValueValidator(0)])),
('output', models.BooleanField(default=False)),
('part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='part.Part')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project.Project')),
],
),
migrations.CreateModel(
name='ProjectRun',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.PositiveIntegerField(default=1, validators=[django.core.validators.MinValueValidator(0)])),
('run_date', models.DateField(blank=True, null=True)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project.Project')),
],
),
migrations.AddField(
model_name='project',
name='category',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='project.ProjectCategory'),
),
migrations.AlterUniqueTogether(
name='projectpart',
unique_together=set([('part', 'project')]),
),
migrations.AlterUniqueTogether(
name='project',
unique_together=set([('name', 'category')]),
),
]

View File

@ -1,96 +0,0 @@
from __future__ import unicode_literals
# from django.utils.translation import ugettext as _
from django.db import models
from InvenTree.models import InvenTreeTree
from part.models import Part
from django.core.validators import MinValueValidator
class ProjectCategory(InvenTreeTree):
""" ProjectCategory provides hierarchical organization of Project objects.
Each ProjectCategory can contain zero-or-more child categories,
and in turn can have zero-or-one parent category.
"""
class Meta:
verbose_name = "Project Category"
verbose_name_plural = "Project Categories"
@property
def projects(self):
return self.project_set.all()
class Project(models.Model):
""" A Project takes multiple Part objects.
A project can output zero-or-more Part objects
"""
name = models.CharField(max_length=100)
description = models.CharField(max_length=500, blank=True)
category = models.ForeignKey(ProjectCategory, on_delete=models.CASCADE, related_name='projects')
class Meta:
unique_together = ('name', 'category')
def __str__(self):
return self.name
class ProjectPart(models.Model):
""" A project part associates a single part with a project
The quantity of parts required for a single-run of that project is stored.
The overage is the number of extra parts that are generally used for a single run.
"""
part = models.ForeignKey(Part, on_delete=models.CASCADE)
project = models.ForeignKey(Project, on_delete=models.CASCADE)
quantity = models.PositiveIntegerField(default=1, validators=[MinValueValidator(0)])
class Meta:
unique_together = ('part', 'project')
"""
# TODO - Add overage model fields
# Overage types
OVERAGE_PERCENT = 0
OVERAGE_ABSOLUTE = 1
OVARAGE_CODES = {
OVERAGE_PERCENT: _("Percent"),
OVERAGE_ABSOLUTE: _("Absolute")
}
overage = models.FloatField(default=0)
overage_type = models.PositiveIntegerField(
default=OVERAGE_ABSOLUTE,
choices=OVARAGE_CODES.items(),
validators=[MinValueValidator(0)])
"""
# Set if the part is generated by the project,
# rather than being consumed by the project
output = models.BooleanField(default=False)
def __str__(self):
return "{quan} x {name}".format(
name=self.part.name,
quan=self.quantity)
class ProjectRun(models.Model):
""" A single run of a particular project.
Tracks the number of 'units' made in the project.
Provides functionality to update stock,
based on both:
a) Parts used (project inputs)
b) Parts produced (project outputs)
"""
project = models.ForeignKey(Project, on_delete=models.CASCADE)
quantity = models.PositiveIntegerField(default=1, validators=[MinValueValidator(0)])
run_date = models.DateField(blank=True, null=True)

View File

@ -1,47 +0,0 @@
from rest_framework import serializers
from .models import ProjectCategory, Project, ProjectPart, ProjectRun
class ProjectPartSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = ProjectPart
fields = ('url',
'part',
'project',
'quantity',
'output')
class ProjectSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Project
fields = ('url',
'name',
'description',
'category')
class ProjectCategorySerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = ProjectCategory
fields = ('url',
'name',
'description',
'parent',
'pathstring')
class ProjectRunSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = ProjectRun
fields = ('url',
'project',
'quantity',
'run_date')
read_only_fields = ('run_date',)

View File

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

View File

@ -1,39 +0,0 @@
from django.conf.urls import url
from . import views
prj_part_urls = [
# Detail of a single project part
url(r'^(?P<pk>[0-9]+)/?$', views.ProjectPartDetail.as_view(), name='projectpart-detail'),
# List project parts, with optional filters
url(r'^\?.*/?$', views.ProjectPartsList.as_view()),
url(r'^$', views.ProjectPartsList.as_view())
]
prj_cat_urls = [
# Detail of a single project category
url(r'^(?P<pk>[0-9]+)/?$', views.ProjectCategoryDetail.as_view(), name='projectcategory-detail'),
# List of project categories, with filters
url(r'^\?.*/?$', views.ProjectCategoryList.as_view()),
url(r'^$', views.ProjectCategoryList.as_view())
]
prj_urls = [
# Individual project URL
url(r'^(?P<pk>[0-9]+)/?$', views.ProjectDetail.as_view(), name='project-detail'),
# List of all projects
url(r'^\?.*/?$', views.ProjectList.as_view()),
url(r'^$', views.ProjectList.as_view())
]
prj_run_urls = [
# Individual project URL
url(r'^(?P<pk>[0-9]+)/?$', views.ProjectRunDetail.as_view(), name='projectrun-detail'),
# List of all projects
url(r'^\?.*/?$', views.ProjectRunList.as_view()),
url(r'^$', views.ProjectRunList.as_view())
]

View File

@ -1,183 +0,0 @@
from django_filters.rest_framework import FilterSet, DjangoFilterBackend
from rest_framework import generics, permissions
from InvenTree.models import FilterChildren
from .models import ProjectCategory, Project, ProjectPart, ProjectRun
from .serializers import ProjectSerializer
from .serializers import ProjectCategorySerializer
from .serializers import ProjectPartSerializer
from .serializers import ProjectRunSerializer
class ProjectDetail(generics.RetrieveUpdateDestroyAPIView):
"""
get:
Return a single Project object
post:
Update a Project
delete:
Remove a Project
"""
queryset = Project.objects.all()
serializer_class = ProjectSerializer
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
class ProjectFilter(FilterSet):
class Meta:
model = Project
fields = ['category']
class ProjectList(generics.ListCreateAPIView):
"""
get:
Return a list of all Project objects
(with optional query filters)
post:
Create a new Project
"""
queryset = Project.objects.all()
serializer_class = ProjectSerializer
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
filter_backends = (DjangoFilterBackend,)
filter_class = ProjectFilter
class ProjectCategoryDetail(generics.RetrieveUpdateAPIView):
"""
get:
Return a single ProjectCategory object
post:
Update a ProjectCategory
delete:
Remove a ProjectCategory
"""
queryset = ProjectCategory.objects.all()
serializer_class = ProjectCategorySerializer
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
class ProjectCategoryList(generics.ListCreateAPIView):
"""
get:
Return a list of all ProjectCategory objects
post:
Create a new ProjectCategory
"""
def get_queryset(self):
params = self.request.query_params
categories = ProjectCategory.objects.all()
categories = FilterChildren(categories, params.get('parent', None))
return categories
serializer_class = ProjectCategorySerializer
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
class ProjectPartFilter(FilterSet):
class Meta:
model = ProjectPart
fields = ['project', 'part']
class ProjectPartsList(generics.ListCreateAPIView):
"""
get:
Return a list of all ProjectPart objects
post:
Create a new ProjectPart
"""
serializer_class = ProjectPartSerializer
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
queryset = ProjectPart.objects.all()
filter_backends = (DjangoFilterBackend,)
filter_class = ProjectPartFilter
class ProjectPartDetail(generics.RetrieveUpdateDestroyAPIView):
"""
get:
Return a single ProjectPart object
post:
Update a ProjectPart
delete:
Remove a ProjectPart
"""
queryset = ProjectPart.objects.all()
serializer_class = ProjectPartSerializer
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
class ProjectRunDetail(generics.RetrieveUpdateDestroyAPIView):
"""
get:
Return a single ProjectRun
post:
Update a ProjectRun
delete:
Remove a ProjectRun
"""
queryset = ProjectRun.objects.all()
serializer_class = ProjectRunSerializer
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
class ProjectRunFilter(FilterSet):
class Meta:
model = ProjectRun
fields = ['project']
class ProjectRunList(generics.ListCreateAPIView):
"""
get:
Return a list of all ProjectRun objects
post:
Create a new ProjectRun object
"""
queryset = ProjectRun.objects.all()
serializer_class = ProjectRunSerializer
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
filter_backends = (DjangoFilterBackend,)
filter_class = ProjectRunFilter

View File

@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2018-04-16 14:03
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('build', '0001_initial'),
('stock', '0009_auto_20180416_1253'),
]
operations = [
migrations.AddField(
model_name='stockitem',
name='build',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='build.Build'),
),
]

View File

@ -3,16 +3,16 @@ 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 django.db.models.signals import pre_delete
from django.dispatch import receiver
from datetime import datetime
from supplier.models import SupplierPart
from supplier.models import Customer
from part.models import Part
from InvenTree.models import InvenTreeTree
from datetime import datetime
from django.db.models.signals import pre_delete
from django.dispatch import receiver
from build.models import Build
class StockLocation(InvenTreeTree):
@ -38,13 +38,8 @@ def before_delete_stock_location(sender, instance, using, **kwargs):
# Update each part in the stock location
for item in instance.items.all():
# 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():
@ -100,6 +95,9 @@ class StockItem(models.Model):
batch = models.CharField(max_length=100, blank=True,
help_text='Batch code for this stock item')
# If this part was produced by a build, point to that build here
build = models.ForeignKey(Build, on_delete=models.SET_NULL, blank=True, null=True)
# Quantity of this stock item. Value may be overridden by other settings
quantity = models.PositiveIntegerField(validators=[MinValueValidator(0)])
@ -113,7 +111,6 @@ class StockItem(models.Model):
review_needed = models.BooleanField(default=False)
# Stock status types
ITEM_OK = 10
ITEM_ATTENTION = 50
ITEM_DAMAGED = 55

View File

@ -11,7 +11,7 @@ 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.
the top level 'Stock' location.
{% endif %}
</p>
@ -27,7 +27,7 @@ the top level 'Stock' category.
{% 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!
If this location is deleted, these items will be moved to the top level 'Stock' location.
{% endif %}
</p>

View File

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