Merge remote-tracking branch 'origin/master'

This commit is contained in:
Oliver Walters 2017-04-12 14:27:08 +10:00
commit 16f3ca589b
39 changed files with 671 additions and 274 deletions

6
.gitattributes vendored Normal file
View File

@ -0,0 +1,6 @@
* text=auto
*.py text
*.md text
*.html text
*.txt text

View File

@ -3,11 +3,9 @@ python:
- 3.4
before_install:
- pip install pep8
- pip install django
- pip install djangorestframework
- make setup
- make setup_ci
script:
- "pep8 --exclude=migrations --ignore=E402,W293,E501 InvenTree"
- python InvenTree/manage.py check
- python InvenTree/manage.py test --noinput
- make style
- make test

View File

@ -1,17 +1,16 @@
from __future__ import unicode_literals
from django.db import models
from django.core.exceptions import ObjectDoesNotExist
from django.contrib.contenttypes.models import ContentType
class Company(models.Model):
""" Abstract model representing an external company
"""
class Meta:
abstract = True
name = models.CharField(max_length=100)
URL = models.URLField(blank=True)
address = models.CharField(max_length=200,
@ -33,93 +32,116 @@ class InvenTreeTree(models.Model):
- Each Category has one parent Category, which can be blank (for a top-level Category).
- Each Category can have zero-or-more child Categor(y/ies)
"""
class Meta:
abstract = True
name = models.CharField(max_length=100)
description = models.CharField(max_length=250)
description = models.CharField(max_length=250, blank=True)
parent = models.ForeignKey('self',
on_delete=models.CASCADE,
blank=True,
null=True)
null=True,
related_name='children')
def getUniqueParents(self, unique=None):
""" Return a flat set of all parent items that exist above this node.
If any parents are repeated (which would be very bad!), the process is halted
"""
if unique is None:
unique = set()
else:
unique.add(self.id)
if self.parent and self.parent.id not in unique:
self.parent.getUniqueParents(unique)
return unique
def getUniqueChildren(self, unique=None):
""" Return a flat set of all child items that exist under this node.
If any child items are repeated, the repetitions are omitted.
"""
if unique is None:
unique = set()
if self.id in unique:
return unique
unique.add(self.id)
# Some magic to get around the limitations of abstract models
contents = ContentType.objects.get_for_model(type(self))
children = contents.get_all_objects_for_this_type(parent=self.id)
for child in children:
child.getUniqueChildren(unique)
return unique
@property
def children(self):
contents = ContentType.objects.get_for_model(type(self))
children = contents.get_all_objects_for_this_type(parent=self.id)
return children
def getAcceptableParents(self):
""" Returns a list of acceptable parent items within this model
Acceptable parents are ones which are not underneath this item.
Setting the parent of an item to its own child results in recursion.
"""
contents = ContentType.objects.get_for_model(type(self))
available = contents.get_all_objects_for_this_type()
# List of child IDs
childs = getUniqueChildren()
childs = self.getUniqueChildren()
acceptable = [None]
for a in available:
if a.id not in childs:
acceptable.append(a)
return acceptable
@property
def parentpath(self):
""" Return the parent path of this category
Todo:
This function is recursive and expensive.
It should be reworked such that only a single db call is required
"""
if self.parent:
return self.parent.parentpath + [self.parent]
else:
return []
@property
def path(self):
if self.parent:
return "/".join([p.name for p in self.parentpath]) + "/" + self.name
else:
return self.name
def __setattr__(self, attrname, val):
""" Custom Attribute Setting function
Parent:
Setting the parent of an item to its own child results in an infinite loop.
The parent of an item cannot be set to:
a) Its own ID
b) The ID of any child items that exist underneath it
Name:
Tree node names are limited to a reduced character set
"""
if attrname == 'parent_id':
# If current ID is None, continue
# - This object is just being created
@ -140,14 +162,14 @@ class InvenTreeTree(models.Model):
# Prohibit certain characters from tree node names
elif attrname == 'name':
val = val.translate({ord(c): None for c in "!@#$%^&*'\"\\/[]{}<>,|+=~`"})
super(InvenTreeTree, self).__setattr__(attrname, val)
def __str__(self):
""" String representation of a category is the full path to that category
Todo:
This is recursive - Make it not so.
"""
return self.path

View File

@ -20,6 +20,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
# TODO: remove this
SECRET_KEY = 'oc2z%5)lu#jsxi#wpg)700z@v48)2aa_yn(a(3qg!z!fw&tr9f'
# SECURITY WARNING: don't run with debug turned on in production!
@ -40,7 +41,7 @@ INSTALLED_APPS = [
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# InvenTree apps
'part.apps.PartConfig',
'project.apps.ProjectConfig',

View File

@ -1,19 +1,3 @@
"""InvenTree URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/1.10/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: url(r'^$', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.conf.urls import url, include
2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls'))
"""
from django.conf.urls import url, include
from django.contrib import admin

View File

@ -11,7 +11,7 @@ if __name__ == "__main__":
# issue is really that Django is missing to avoid masking other
# exceptions on Python 2.
try:
import django
import django # NOQA
except ImportError:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "

View File

@ -4,14 +4,14 @@ from .models import PartCategory, Part, PartParameter, PartParameterTemplate, Ca
class PartAdmin(admin.ModelAdmin):
list_display = ('name', 'IPN', 'stock', 'category')
class PartCategoryAdmin(admin.ModelAdmin):
list_display = ('name', 'path', 'description')
class ParameterTemplateAdmin(admin.ModelAdmin):
list_display = ('name', 'units', 'format')
@ -20,7 +20,7 @@ class ParameterTemplateAdmin(admin.ModelAdmin):
class ParameterAdmin(admin.ModelAdmin):
list_display = ('part', 'template', 'value')
admin.site.register(Part, PartAdmin)
admin.site.register(PartCategory, PartCategoryAdmin)

View File

@ -1,8 +1,7 @@
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.exceptions import ObjectDoesNotExist, ValidationError
from InvenTree.models import InvenTreeTree
@ -10,23 +9,42 @@ from InvenTree.models import InvenTreeTree
class PartCategory(InvenTreeTree):
""" PartCategory provides hierarchical organization of Part objects.
"""
class Meta:
verbose_name = "Part Category"
verbose_name_plural = "Part Categories"
@property
def parts(self):
return self.part_set.all()
class Part(models.Model):
""" Represents a """
# Short name of the part
name = models.CharField(max_length=100)
# Longer description of the part (optional)
description = models.CharField(max_length=250, blank=True)
# Internal Part Number (optional)
IPN = models.CharField(max_length=100, blank=True)
# Part category - all parts must be assigned to a category
category = models.ForeignKey(PartCategory, on_delete=models.CASCADE)
minimum_stock = models.IntegerField(default=0)
# Minimum "allowed" stock level
minimum_stock = models.PositiveIntegerField(default=0)
# Units of quantity for this part. Default is "pcs"
units = models.CharField(max_length=20, default="pcs", blank=True)
# Is this part "trackable"?
# Trackable parts can have unique instances which are assigned serial numbers
# and can have their movements tracked
trackable = models.BooleanField(default=False)
def __str__(self):
if self.IPN:
return "{name} ({ipn})".format(
@ -34,46 +52,40 @@ class Part(models.Model):
name=self.name)
else:
return self.name
class Meta:
verbose_name = "Part"
verbose_name_plural = "Parts"
@property
def stock_list(self):
""" Return a list of all stock objects associated with this part
"""
return self.stockitem_set.all()
@property
def stock(self):
""" Return the total stock quantity for this part.
Part may be stored in multiple locations
"""
stocks = self.stock_list
stocks = self.locations.all()
if len(stocks) == 0:
return 0
result = stocks.aggregate(total=Sum('quantity'))
return result['total']
@property
def projects(self):
""" Return a list of unique projects that this part is associated with
""" Return a list of unique projects that this part is associated with.
A part may be used in zero or more projects.
"""
project_ids = set()
project_parts = self.projectpart_set.all()
projects = []
for pp in project_parts:
if pp.project.id not in project_ids:
project_ids.add(pp.project.id)
projects.append(pp.project)
return projects
@ -85,28 +97,31 @@ class PartParameterTemplate(models.Model):
name = models.CharField(max_length=20)
description = models.CharField(max_length=100, blank=True)
units = models.CharField(max_length=10, blank=True)
default_value = models.CharField(max_length=50, blank=True)
default_min = models.CharField(max_length=50, blank=True)
default_max = models.CharField(max_length=50, blank=True)
# Parameter format
PARAM_NUMERIC = 10
PARAM_TEXT = 20
PARAM_BOOL = 30
format = models.IntegerField(
PARAM_TYPE_CODES = {
PARAM_NUMERIC: _("Numeric"),
PARAM_TEXT: _("Text"),
PARAM_BOOL: _("Bool")
}
format = models.PositiveIntegerField(
default=PARAM_NUMERIC,
choices=[
(PARAM_NUMERIC, "Numeric"),
(PARAM_TEXT, "Text"),
(PARAM_BOOL, "Boolean")])
choices=PARAM_TYPE_CODES.items())
def __str__(self):
return "{name} ({units})".format(
name=self.name,
units=self.units)
class Meta:
verbose_name = "Parameter Template"
verbose_name_plural = "Parameter Templates"
@ -117,7 +132,7 @@ class CategoryParameterLink(models.Model):
"""
category = models.ForeignKey(PartCategory, on_delete=models.CASCADE)
template = models.ForeignKey(PartParameterTemplate, on_delete=models.CASCADE)
def __str__(self):
return "{name} - {cat}".format(
name=self.template.name,
@ -126,42 +141,49 @@ class CategoryParameterLink(models.Model):
class Meta:
verbose_name = "Category Parameter"
verbose_name_plural = "Category Parameters"
class PartParameter(models.Model):
""" PartParameter is associated with a single part
"""
part = models.ForeignKey(Part, on_delete=models.CASCADE)
part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='parameters')
template = models.ForeignKey(PartParameterTemplate)
# Value data
value = models.CharField(max_length=50, blank=True)
min_value = models.CharField(max_length=50, blank=True)
max_value = models.CharField(max_length=50, blank=True)
# Prevent multiple parameters of the same template
# from being added to the same part
def save(self, *args, **kwargs):
params = PartParameter.objects.filter(part=self.part, template=self.template)
if len(params) > 0:
raise ValidationError("Parameter '{param}' already exists for {part}".format(
param=self.template.name,
part=self.part.name))
if len(params) > 1:
return
if len(params) == 1 and params[0].id != self.id:
return
super(PartParameter, self).save(*args, **kwargs)
def __str__(self):
return "{name} : {val}{units}".format(
name=self.template.name,
val=self.value,
units=self.template.units)
@property
def units(self):
return self.template.units
@property
def name(self):
return self.template.name
class Meta:
verbose_name = "Part Parameter"
verbose_name_plural = "Part Parameters"
class PartRevision(models.Model):
""" A PartRevision represents a change-notification to a Part
@ -169,12 +191,12 @@ class PartRevision(models.Model):
which should be tracked.
UniqueParts can have a single associated PartRevision
"""
part = models.ForeignKey(Part, on_delete=models.CASCADE)
name = models.CharField(max_length=100)
description = models.CharField(max_length=500)
revision_date = models.DateField(auto_now_add=True)
def __str__(self):
return self.name

View File

@ -1,22 +1,60 @@
from rest_framework import serializers
from .models import Part, PartCategory
from .models import Part, PartCategory, PartParameter
class PartParameterSerializer(serializers.ModelSerializer):
""" Serializer for a PartParameter
"""
class Meta:
model = PartParameter
fields = ('pk',
'part',
'template',
'name',
'value',
'units')
class PartSerializer(serializers.ModelSerializer):
""" Serializer for complete detail information of a part.
Used when displaying all details of a single component.
"""
class Meta:
model = Part
fields = ('pk',
'name',
'IPN',
'description',
'category',
'stock')
class PartCategorySerializer(serializers.ModelSerializer):
class PartCategoryBriefSerializer(serializers.ModelSerializer):
class Meta:
model = PartCategory
fields = ('pk',
'name',
'description')
class PartCategoryDetailSerializer(serializers.ModelSerializer):
# List of parts in this category
parts = PartSerializer(many=True)
# List of child categories under this one
children = PartCategoryBriefSerializer(many=True)
class Meta:
model = PartCategory
fields = ('pk',
'name',
'description',
'path')
'parent',
'path',
'children',
'parts')

View File

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

View File

@ -3,15 +3,18 @@ from django.conf.urls import url
from . import views
urlpatterns = [
# Display part detail
# Single part detail
url(r'^(?P<pk>[0-9]+)/$', views.PartDetail.as_view()),
# Display a single part category
# Part parameters list
url(r'^(?P<pk>[0-9]+)/parameters/$', views.PartParameters.as_view()),
# Part category detail
url(r'^category/(?P<pk>[0-9]+)/$', views.PartCategoryDetail.as_view()),
# Display a list of top-level categories
# List of top-level categories
url(r'^category/$', views.PartCategoryList.as_view()),
# Display list of parts
# List of all parts
url(r'^$', views.PartList.as_view())
]

View File

@ -1,14 +1,9 @@
from django.shortcuts import render, get_object_or_404
from django.http import HttpResponse, Http404
from rest_framework import generics
from .models import PartCategory, Part
from .serializers import PartSerializer, PartCategorySerializer
def index(request):
return HttpResponse("Hello world. This is the parts page")
from .models import PartCategory, Part, PartParameter
from .serializers import PartSerializer
from .serializers import PartCategoryDetailSerializer
from .serializers import PartParameterSerializer
class PartDetail(generics.RetrieveAPIView):
@ -17,6 +12,15 @@ class PartDetail(generics.RetrieveAPIView):
serializer_class = PartSerializer
class PartParameters(generics.ListAPIView):
def get_queryset(self):
part_id = self.kwargs['pk']
return PartParameter.objects.filter(part=part_id)
serializer_class = PartParameterSerializer
class PartList(generics.ListAPIView):
queryset = Part.objects.all()
@ -24,12 +28,15 @@ class PartList(generics.ListAPIView):
class PartCategoryDetail(generics.RetrieveAPIView):
""" Return information on a single PartCategory
"""
queryset = PartCategory.objects.all()
serializer_class = PartCategorySerializer
serializer_class = PartCategoryDetailSerializer
class PartCategoryList(generics.ListAPIView):
queryset = PartCategory.objects.all()
serializer_class = PartCategorySerializer
""" Return a list of all top-level part categories.
Categories are considered "top-level" if they do not have a parent
"""
queryset = PartCategory.objects.filter(parent=None)
serializer_class = PartCategoryDetailSerializer

View File

@ -1,6 +1,6 @@
from django.contrib import admin
from .models import ProjectCategory, Project, ProjectPart
from .models import ProjectCategory, Project, ProjectPart, ProjectRun
class ProjectCategoryAdmin(admin.ModelAdmin):
@ -12,8 +12,14 @@ class ProjectAdmin(admin.ModelAdmin):
class ProjectPartAdmin(admin.ModelAdmin):
list_display = ('part', 'project', 'quantity')
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,4 +1,5 @@
from __future__ import unicode_literals
from django.utils.translation import ugettext as _
from django.db import models
@ -11,24 +12,28 @@ class ProjectCategory(InvenTreeTree):
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)
def __str__(self):
return self.name
@property
def projectParts(self):
""" Return a list of all project parts associated with this project
@ -41,23 +46,44 @@ class ProjectPart(models.Model):
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.
"""
# Overage types
OVERAGE_PERCENT = 0
OVERAGE_ABSOLUTE = 1
OVARAGE_CODES = {
OVERAGE_PERCENT: _("Percent"),
OVERAGE_ABSOLUTE: _("Absolute")
}
part = models.ForeignKey(Part, on_delete=models.CASCADE)
project = models.ForeignKey(Project, on_delete=models.CASCADE)
quantity = models.IntegerField(default=1)
quantity = models.PositiveIntegerField(default=1)
overage = models.FloatField(default=0)
overage_type = models.IntegerField(
default=1,
choices=[
(OVERAGE_PERCENT, "Percent"),
(OVERAGE_ABSOLUTE, "Absolute")
])
overage_type = models.PositiveIntegerField(
default=OVERAGE_ABSOLUTE,
choices=OVARAGE_CODES.items())
# 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)
run_date = models.DateField(auto_now_add=True)

View File

@ -0,0 +1,64 @@
from rest_framework import serializers
from .models import ProjectCategory, Project, ProjectPart
class ProjectPartSerializer(serializers.ModelSerializer):
class Meta:
model = ProjectPart
fields = ('pk',
'part',
'project',
'quantity',
'overage',
'overage_type',
'output')
class ProjectBriefSerializer(serializers.ModelSerializer):
""" Serializer for displaying brief overview of a project
"""
class Meta:
model = Project
fields = ('pk',
'name',
'description',
'category')
class ProjectDetailSerializer(serializers.ModelSerializer):
""" Serializer for detailed project information
"""
class Meta:
model = Project
fields = ('pk',
'name',
'description',
'category')
class ProjectCategoryBriefSerializer(serializers.ModelSerializer):
class Meta:
model = ProjectCategory
fields = ('pk', 'name', 'description')
class ProjectCategoryDetailSerializer(serializers.ModelSerializer):
projects = ProjectBriefSerializer(many=True)
children = ProjectCategoryBriefSerializer(many=True)
class Meta:
model = ProjectCategory
fields = ('pk',
'name',
'description',
'parent',
'path',
'children',
'projects')

View File

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

View File

@ -3,5 +3,18 @@ from django.conf.urls import url
from . import views
urlpatterns = [
url(r'^$', views.index, name='index')
# Single project detail
url(r'^(?P<pk>[0-9]+)/$', views.ProjectDetail.as_view()),
# Parts associated with a project
url(r'^(?P<pk>[0-9]+)/parts$', views.ProjectPartsList.as_view()),
# List of all projects
url(r'^$', views.ProjectList.as_view()),
# List of top-level project categories
url(r'^category/$', views.ProjectCategoryList.as_view()),
# Detail of a single project category
url(r'^category/(?P<pk>[0-9]+)/$', views.ProjectCategoryDetail.as_view())
]

View File

@ -1,6 +1,39 @@
from django.shortcuts import render, get_object_or_404
from django.http import HttpResponse
from rest_framework import generics
from .models import ProjectCategory, Project, ProjectPart
from .serializers import ProjectBriefSerializer, ProjectDetailSerializer
from .serializers import ProjectCategoryDetailSerializer
from .serializers import ProjectPartSerializer
def index(request):
return HttpResponse("This is the Projects page")
class ProjectDetail(generics.RetrieveAPIView):
queryset = Project.objects.all()
serializer_class = ProjectDetailSerializer
class ProjectList(generics.ListAPIView):
queryset = Project.objects.all()
serializer_class = ProjectBriefSerializer
class ProjectCategoryDetail(generics.RetrieveAPIView):
queryset = ProjectCategory.objects.all()
serializer_class = ProjectCategoryDetailSerializer
class ProjectCategoryList(generics.ListAPIView):
queryset = ProjectCategory.objects.filter(parent=None)
serializer_class = ProjectCategoryDetailSerializer
class ProjectPartsList(generics.ListAPIView):
serializer_class = ProjectPartSerializer
def get_queryset(self):
project_id = self.kwargs['pk']
return ProjectPart.objects.filter(project=project_id)

View File

@ -1,14 +1,15 @@
from django.contrib import admin
from .models import Warehouse, StockItem
from .models import StockLocation, StockItem
class WarehouseAdmin(admin.ModelAdmin):
class LocationAdmin(admin.ModelAdmin):
list_display = ('name', 'path', 'description')
class StockItemAdmin(admin.ModelAdmin):
list_display = ('part', 'quantity', 'location', 'status', 'updated')
admin.site.register(Warehouse, WarehouseAdmin)
admin.site.register(StockLocation, LocationAdmin)
admin.site.register(StockItem, StockItemAdmin)

View File

@ -1,36 +1,60 @@
from __future__ import unicode_literals
from django.utils.translation import ugettext as _
from django.db import models
from part.models import Part
from InvenTree.models import InvenTreeTree
class Warehouse(InvenTreeTree):
pass
class StockLocation(InvenTreeTree):
""" Organization tree for StockItem objects
"""
@property
def items(self):
stock_list = self.stockitem_set.all()
return stock_list
class StockItem(models.Model):
part = models.ForeignKey(Part,
on_delete=models.CASCADE)
location = models.ForeignKey(Warehouse, on_delete=models.CASCADE)
quantity = models.IntegerField()
on_delete=models.CASCADE,
related_name='locations')
location = models.ForeignKey(StockLocation, on_delete=models.CASCADE)
quantity = models.PositiveIntegerField()
updated = models.DateField(auto_now=True)
# last time the stock was checked / counted
last_checked = models.DateField(blank=True, null=True)
review_needed = models.BooleanField(default=False)
# Stock status types
ITEM_IN_PROGRESS = 0
ITEM_DAMAGED = 10
ITEM_ATTENTION = 20
ITEM_COMPLETE = 50
status = models.IntegerField(default=ITEM_IN_PROGRESS,
choices=[
(ITEM_IN_PROGRESS, "In progress"),
(ITEM_DAMAGED, "Damaged"),
(ITEM_ATTENTION, "Requires attention"),
(ITEM_COMPLETE, "Complete")
])
ITEM_IN_STOCK = 10
ITEM_INCOMING = 15
ITEM_IN_PROGRESS = 20
ITEM_COMPLETE = 25
ITEM_ATTENTION = 50
ITEM_DAMAGED = 55
ITEM_DESTROYED = 60
ITEM_STATUS_CODES = {
ITEM_IN_STOCK: _("In stock"),
ITEM_INCOMING: _("Incoming"),
ITEM_IN_PROGRESS: _("In progress"),
ITEM_COMPLETE: _("Complete"),
ITEM_ATTENTION: _("Attention needed"),
ITEM_DAMAGED: _("Damaged"),
ITEM_DESTROYED: _("Destroyed")
}
status = models.PositiveIntegerField(
default=ITEM_IN_STOCK,
choices=ITEM_STATUS_CODES.items())
# If stock item is incoming, an (optional) ETA field
expected_arrival = models.DateField(null=True, blank=True)
def __str__(self):
return "{n} x {part} @ {loc}".format(
n=self.quantity,

View File

@ -0,0 +1,52 @@
from rest_framework import serializers
from .models import StockItem, StockLocation
class StockItemSerializer(serializers.ModelSerializer):
""" Serializer for a StockItem
"""
class Meta:
model = StockItem
fields = ('pk',
'part',
'location',
'quantity',
'status',
'updated',
'last_checked',
'review_needed',
'expected_arrival')
class LocationBriefSerializer(serializers.ModelSerializer):
""" Brief information about a stock location
"""
class Meta:
model = StockLocation
fields = ('pk',
'name',
'description')
class LocationDetailSerializer(serializers.ModelSerializer):
""" Detailed information about a stock location
"""
# List of all stock items in this location
items = StockItemSerializer(many=True)
# List of all child locations under this one
children = LocationBriefSerializer(many=True)
class Meta:
model = StockLocation
fields = ('pk',
'name',
'description',
'parent',
'path',
'children',
'items')

View File

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

View File

@ -3,5 +3,12 @@ from django.conf.urls import url
from . import views
urlpatterns = [
url(r'^$', views.index, name='index')
# List all stock quantities for a given part
url(r'^part/(?P<part>[0-9]+)$', views.PartStockDetail.as_view()),
# List all stock items in a given location
url(r'^location/(?P<pk>[0-9]+)$', views.LocationDetail.as_view()),
# List all top-level locations
url(r'^location/$', views.LocationList.as_view())
]

View File

@ -1,11 +1,33 @@
from django.shortcuts import render, get_object_or_404
from django.http import HttpResponse
from rest_framework import generics
from .models import Warehouse, StockItem
from .models import StockLocation, StockItem
from .serializers import StockItemSerializer, LocationDetailSerializer
def index(request):
warehouses = Warehouse.objects.filter(parent=None)
return render(request, 'stock/index.html', {'warehouses': warehouses})
class PartStockDetail(generics.ListAPIView):
""" Return a list of all stockitems for a given part
"""
serializer_class = StockItemSerializer
def get_queryset(self):
part_id = self.kwargs['part']
return StockItem.objects.filter(part=part_id)
class LocationDetail(generics.RetrieveAPIView):
""" Return information on a specific stock location
"""
queryset = StockLocation.objects.all()
serializer_class = LocationDetailSerializer
class LocationList(generics.ListAPIView):
""" Return a list of top-level locations
Locations are considered "top-level" if they do not have a parent
"""
queryset = StockLocation.objects.filter(parent=None)
serializer_class = LocationDetailSerializer

View File

@ -6,6 +6,7 @@ from .models import Supplier, SupplierPart, Customer, Manufacturer
class CompanyAdmin(admin.ModelAdmin):
list_display = ('name', 'URL', 'contact')
admin.site.register(Customer, CompanyAdmin)
admin.site.register(Supplier, CompanyAdmin)
admin.site.register(Manufacturer, CompanyAdmin)

View File

@ -9,9 +9,8 @@ from part.models import Part
class Supplier(Company):
""" Represents a manufacturer or supplier
"""
pass
class Manufacturer(Company):
""" Represents a manfufacturer
@ -32,21 +31,37 @@ class SupplierPart(models.Model):
- A Part may be available from multiple suppliers
"""
part = models.ForeignKey(Part,
on_delete=models.CASCADE)
supplier = models.ForeignKey(Supplier,
on_delete=models.CASCADE)
part = models.ForeignKey(Part, null=True, blank=True, on_delete=models.CASCADE)
supplier = models.ForeignKey(Supplier, on_delete=models.CASCADE)
SKU = models.CharField(max_length=100)
manufacturer = models.ForeignKey(Manufacturer, blank=True, null=True, on_delete=models.CASCADE)
MPN = models.CharField(max_length=100, blank=True)
URL = models.URLField(blank=True)
description = models.CharField(max_length=250, blank=True)
# Default price for a single unit
single_price = models.DecimalField(max_digits=10, decimal_places=3, default=0)
# Base charge added to order independent of quantity e.g. "Reeling Fee"
base_cost = models.DecimalField(max_digits=10, decimal_places=3, default=0)
# packaging that the part is supplied in, e.g. "Reel"
packaging = models.CharField(max_length=50, blank=True)
# multiple that the part is provided in
multiple = models.PositiveIntegerField(default=1)
# Mimumum number required to order
minimum = models.PositiveIntegerField(default=1)
# lead time for parts that cannot be delivered immediately
lead_time = models.DurationField(blank=True, null=True)
def __str__(self):
return "{mpn} - {supplier}".format(
mpn=self.MPN,
return "{sku} - {supplier}".format(
sku=self.SKU,
supplier=self.supplier.name)
@ -56,16 +71,13 @@ class SupplierPriceBreak(models.Model):
- SupplierPart(s) may have zero-or-more associated SupplierPriceBreak(s)
"""
part = models.ForeignKey(SupplierPart,
on_delete=models.CASCADE)
quantity = models.IntegerField()
part = models.ForeignKey(SupplierPart, on_delete=models.CASCADE, related_name='price_breaks')
quantity = models.PositiveIntegerField()
cost = models.DecimalField(max_digits=10, decimal_places=3)
currency = models.CharField(max_length=10,
blank=True)
def __str__(self):
return "{mpn} - {cost}{currency} @ {quan}".format(
mpn=part.MPN,
mpn=self.part.MPN,
cost=self.cost,
currency=self.currency if self.currency else '',
quan=self.quantity)

View File

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

View File

@ -4,6 +4,7 @@ from .models import UniquePart
class UniquePartAdmin(admin.ModelAdmin):
list_display = ('part', 'revision', 'serial', 'creation_date')
list_display = ('part', 'revision', 'serial', 'status', 'creation_date')
admin.site.register(UniquePart, UniquePartAdmin)

View File

@ -1,5 +1,5 @@
from __future__ import unicode_literals
from django.utils.translation import ugettext as _
from django.db import models
from django.contrib.auth.models import User
@ -36,15 +36,16 @@ class UniquePart(models.Model):
PART_DAMAGED = 40
PART_DESTROYED = 50
status = models.IntegerField(default=PART_IN_PROGRESS,
choices=[
(PART_IN_PROGRESS, "In progress"),
(PART_IN_STOCK, "In stock"),
(PART_SHIPPED, "Shipped"),
(PART_RETURNED, "Returned"),
(PART_DAMAGED, "Damaged"),
(PART_DESTROYED, "Destroyed"),
])
PART_STATUS_CODES = {
PART_IN_PROGRESS: _("In progress"),
PART_IN_STOCK: _("In stock"),
PART_SHIPPED: _("Shipped"),
PART_RETURNED: _("Returned"),
PART_DAMAGED: _("Damaged"),
PART_DESTROYED: _("Destroyed")
}
status = models.IntegerField(default=PART_IN_PROGRESS, choices=PART_STATUS_CODES.items())
def __str__(self):
return self.part.name

View File

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

View File

@ -1,4 +1,3 @@
from django.shortcuts import render, get_object_or_404
from django.http import HttpResponse

34
Makefile Normal file
View File

@ -0,0 +1,34 @@
clean:
find . -path '*/__pycache__/*' -delete
find . -type d -name '__pycache__' -empty -delete
find . -name *.pyc -o -name *.pyo -delete
rm -rf *.egg-info
rm -rf .cache
rm -rf .tox
rm -f .coverage
style:
flake8
test:
python InvenTree/manage.py test --noinput
migrate:
python InvenTree/manage.py makemigrations
python InvenTree/manage.py migrate --run-syncdb
python InvenTree/manage.py check
install:
# TODO: replace this with a proper setup.py
pip install -U -r requirements/base.txt
setup: install migrate
setup_ci:
pip install -U -r requirements/build.txt
develop:
pip install -U -r requirements/dev.txt
superuser:
python InvenTree/manage.py createsuperuser

View File

@ -1,5 +1,25 @@
# InvenTree
Open Source Inventory Management System
[![Build Status](https://travis-ci.org/inventree/InvenTree.svg?branch=master)](https://travis-ci.org/inventree/InvenTree)
# InvenTree
InvenTree is an open-source Inventory Management System which provides powerful low-level stock control and part tracking. The core of the InvenTree system is a Python/Django database backend which provides an admin interface (web-based) and a JSON API for interaction with external interfaces and applications.
## Installation
It is recommended to set up a clean Python 3.4+ virtual environment first:
`mkdir ~/.env && python3 -m venv ~/.env/InvenTree && source ~/.env/InvenTree/bin/activate`
You can then continue running `make setup` (which will be replaced by a proper setup.py soon). This will do the following:
1. Installs required Python dependencies (requires [pip](https://pypi.python.org/pypi/pip), should be part of your virtual environment by default)
1. Performs initial database setup
1. Updates database tables for all InvenTree components
This command can also be used to update the installation if changes have been made to the database configuration.
To create an initial user account, run the command `make superuser`.
## Documentation
For project code documentation, refer to the online [documentation](http://inventree.readthedocs.io/en/latest/) (auto-generated)
## Coding Style
If you'd like to contribute, install our development dependencies using `make develop`.
All Python code should conform to the [PEP 8](https://www.python.org/dev/peps/pep-0008/) style guide. Run `make style` which will compare all source (.py) files against the PEP 8 style. Tests can be run using `make test`.

View File

@ -1,44 +0,0 @@
from __future__ import print_function
import subprocess
import argparse
def manage(*arg):
args = ["python", "InvenTree/manage.py"]
for a in arg:
args.append(a)
subprocess.call(args)
parser = argparse.ArgumentParser(description="Install InvenTree inventory management system")
parser.add_argument('-u', '--update', help='Update only, do not try to install required components', action='store_true')
args = parser.parse_args()
# If 'update' is specified, don't perform initial installation
if not args.update:
# Install django requirements
subprocess.call(["pip", "install", "django", "-q"])
subprocess.call(["pip", "install", "djangorestframework", "-q"])
# Initial database setup
manage("migrate")
# Make migrations for all apps
manage("makemigrations", "part")
manage("makemigrations", "stock")
manage("makemigrations", "supplier")
manage("makemigrations", "project")
manage("makemigrations", "track")
# Update the database
manage("migrate")
# Check for errors
manage("check")
if not args.update:
print("\n\nAdmin account:\nIf a superuser is not already installed,")
print("run the command 'python InvenTree/manage.py createsuperuser'")

2
requirements/base.txt Normal file
View File

@ -0,0 +1,2 @@
Django==1.11
djangorestframework==3.6.2

2
requirements/build.txt Normal file
View File

@ -0,0 +1,2 @@
-r base.txt
flake8==3.3.0

4
requirements/dev.txt Normal file
View File

@ -0,0 +1,4 @@
-r build.txt
django-extensions==1.7.8
graphviz==0.6
ipython==5.3.0

28
roadmap.md Normal file
View File

@ -0,0 +1,28 @@
## InvenTree Roadmap
### Design Goals
InvenTree is intened to provide a stand-alone stock-management system that runs completely offline.
It is designed to be run on a local server, and should not require the use of plugins/scripts that phone-home or load external content.
(This ignores the use of bespoke plugins that may be implemented down the line, e.g. for OctoPart integration, etc)
### 0.1 Release
The goals for the initial release should be limited to the following:
1. Fully implement a JSON API for the various apps and models
1. Design an initial front-end for querying data using this API
* Single-pase design is preferred, for the sake of responsiveness and intuitive interaction
* Investigate JS/AJAX engine - Angular? Bootstrap?
1. Allow users to view part category tree
1. Allow users to view all parts in a given category
1. "" edit parts
1. "" add new parts
### TODO
Research needed!
django-restful in combination with angular seems the way to go. Extra tools provided via https://github.com/jrief/django-angular

8
setup.cfg Normal file
View File

@ -0,0 +1,8 @@
[flake8]
ignore =
# - W293 - blank lines contain whitespace
W293,
# - E501 - line too long (82 characters)
E501
exclude = .git,__pycache__
max-complexity = 10