2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-29 12:06:44 +00:00

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 - 3.4
before_install: before_install:
- pip install pep8 - make setup
- pip install django - make setup_ci
- pip install djangorestframework
script: script:
- "pep8 --exclude=migrations --ignore=E402,W293,E501 InvenTree" - make style
- python InvenTree/manage.py check - make test
- python InvenTree/manage.py test --noinput

View File

@ -1,7 +1,6 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from django.db import models from django.db import models
from django.core.exceptions import ObjectDoesNotExist
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
@ -38,11 +37,27 @@ class InvenTreeTree(models.Model):
abstract = True abstract = True
name = models.CharField(max_length=100) name = models.CharField(max_length=100)
description = models.CharField(max_length=250) description = models.CharField(max_length=250, blank=True)
parent = models.ForeignKey('self', parent = models.ForeignKey('self',
on_delete=models.CASCADE, on_delete=models.CASCADE,
blank=True, 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): def getUniqueChildren(self, unique=None):
""" Return a flat set of all child items that exist under this node. """ Return a flat set of all child items that exist under this node.
@ -66,6 +81,13 @@ class InvenTreeTree(models.Model):
return 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): def getAcceptableParents(self):
""" Returns a list of acceptable parent items within this model """ Returns a list of acceptable parent items within this model
Acceptable parents are ones which are not underneath this item. Acceptable parents are ones which are not underneath this item.
@ -76,7 +98,7 @@ class InvenTreeTree(models.Model):
available = contents.get_all_objects_for_this_type() available = contents.get_all_objects_for_this_type()
# List of child IDs # List of child IDs
childs = getUniqueChildren() childs = self.getUniqueChildren()
acceptable = [None] acceptable = [None]

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/ # See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret! # 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' 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! # SECURITY WARNING: don't run with debug turned on in production!

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.conf.urls import url, include
from django.contrib import admin 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 # issue is really that Django is missing to avoid masking other
# exceptions on Python 2. # exceptions on Python 2.
try: try:
import django import django # NOQA
except ImportError: except ImportError:
raise ImportError( raise ImportError(
"Couldn't import Django. Are you sure it's installed and " "Couldn't import Django. Are you sure it's installed and "

View File

@ -1,8 +1,7 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from django.utils.translation import ugettext as _
from django.db import models from django.db import models
from django.db.models import Sum from django.db.models import Sum
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from InvenTree.models import InvenTreeTree from InvenTree.models import InvenTreeTree
@ -15,16 +14,35 @@ class PartCategory(InvenTreeTree):
verbose_name = "Part Category" verbose_name = "Part Category"
verbose_name_plural = "Part Categories" verbose_name_plural = "Part Categories"
@property
def parts(self):
return self.part_set.all()
class Part(models.Model): class Part(models.Model):
""" Represents a """ """ Represents a """
# Short name of the part
name = models.CharField(max_length=100) name = models.CharField(max_length=100)
# Longer description of the part (optional)
description = models.CharField(max_length=250, blank=True) description = models.CharField(max_length=250, blank=True)
# Internal Part Number (optional)
IPN = models.CharField(max_length=100, blank=True) 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) 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) 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) trackable = models.BooleanField(default=False)
def __str__(self): def __str__(self):
@ -39,20 +57,13 @@ class Part(models.Model):
verbose_name = "Part" verbose_name = "Part"
verbose_name_plural = "Parts" 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 @property
def stock(self): def stock(self):
""" Return the total stock quantity for this part. """ Return the total stock quantity for this part.
Part may be stored in multiple locations Part may be stored in multiple locations
""" """
stocks = self.stock_list stocks = self.locations.all()
if len(stocks) == 0: if len(stocks) == 0:
return 0 return 0
@ -61,7 +72,8 @@ class Part(models.Model):
@property @property
def projects(self): 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_ids = set()
@ -95,12 +107,15 @@ class PartParameterTemplate(models.Model):
PARAM_TEXT = 20 PARAM_TEXT = 20
PARAM_BOOL = 30 PARAM_BOOL = 30
format = models.IntegerField( PARAM_TYPE_CODES = {
PARAM_NUMERIC: _("Numeric"),
PARAM_TEXT: _("Text"),
PARAM_BOOL: _("Bool")
}
format = models.PositiveIntegerField(
default=PARAM_NUMERIC, default=PARAM_NUMERIC,
choices=[ choices=PARAM_TYPE_CODES.items())
(PARAM_NUMERIC, "Numeric"),
(PARAM_TEXT, "Text"),
(PARAM_BOOL, "Boolean")])
def __str__(self): def __str__(self):
return "{name} ({units})".format( return "{name} ({units})".format(
@ -132,8 +147,7 @@ class PartParameter(models.Model):
""" PartParameter is associated with a single part """ 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) template = models.ForeignKey(PartParameterTemplate)
# Value data # Value data
@ -145,10 +159,10 @@ class PartParameter(models.Model):
# from being added to the same part # from being added to the same part
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
params = PartParameter.objects.filter(part=self.part, template=self.template) params = PartParameter.objects.filter(part=self.part, template=self.template)
if len(params) > 0: if len(params) > 1:
raise ValidationError("Parameter '{param}' already exists for {part}".format( return
param=self.template.name, if len(params) == 1 and params[0].id != self.id:
part=self.part.name)) return
super(PartParameter, self).save(*args, **kwargs) super(PartParameter, self).save(*args, **kwargs)
@ -158,6 +172,14 @@ class PartParameter(models.Model):
val=self.value, val=self.value,
units=self.template.units) units=self.template.units)
@property
def units(self):
return self.template.units
@property
def name(self):
return self.template.name
class Meta: class Meta:
verbose_name = "Part Parameter" verbose_name = "Part Parameter"
verbose_name_plural = "Part Parameters" verbose_name_plural = "Part Parameters"

View File

@ -1,22 +1,60 @@
from rest_framework import serializers 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): class PartSerializer(serializers.ModelSerializer):
""" Serializer for complete detail information of a part.
Used when displaying all details of a single component.
"""
class Meta: class Meta:
model = Part model = Part
fields = ('pk', fields = ('pk',
'name',
'IPN', 'IPN',
'description', 'description',
'category', 'category',
'stock') '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: class Meta:
model = PartCategory model = PartCategory
fields = ('pk', fields = ('pk',
'name', 'name',
'description', '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. # Create your tests here.

View File

@ -3,15 +3,18 @@ from django.conf.urls import url
from . import views from . import views
urlpatterns = [ urlpatterns = [
# Display part detail # Single part detail
url(r'^(?P<pk>[0-9]+)/$', views.PartDetail.as_view()), 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()), 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()), url(r'^category/$', views.PartCategoryList.as_view()),
# Display list of parts # List of all parts
url(r'^$', views.PartList.as_view()) 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 rest_framework import generics
from .models import PartCategory, Part from .models import PartCategory, Part, PartParameter
from .serializers import PartSerializer, PartCategorySerializer from .serializers import PartSerializer
from .serializers import PartCategoryDetailSerializer
from .serializers import PartParameterSerializer
def index(request):
return HttpResponse("Hello world. This is the parts page")
class PartDetail(generics.RetrieveAPIView): class PartDetail(generics.RetrieveAPIView):
@ -17,6 +12,15 @@ class PartDetail(generics.RetrieveAPIView):
serializer_class = PartSerializer 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): class PartList(generics.ListAPIView):
queryset = Part.objects.all() queryset = Part.objects.all()
@ -24,12 +28,15 @@ class PartList(generics.ListAPIView):
class PartCategoryDetail(generics.RetrieveAPIView): class PartCategoryDetail(generics.RetrieveAPIView):
""" Return information on a single PartCategory
"""
queryset = PartCategory.objects.all() queryset = PartCategory.objects.all()
serializer_class = PartCategorySerializer serializer_class = PartCategoryDetailSerializer
class PartCategoryList(generics.ListAPIView): class PartCategoryList(generics.ListAPIView):
""" Return a list of all top-level part categories.
queryset = PartCategory.objects.all() Categories are considered "top-level" if they do not have a parent
serializer_class = PartCategorySerializer """
queryset = PartCategory.objects.filter(parent=None)
serializer_class = PartCategoryDetailSerializer

View File

@ -1,6 +1,6 @@
from django.contrib import admin from django.contrib import admin
from .models import ProjectCategory, Project, ProjectPart from .models import ProjectCategory, Project, ProjectPart, ProjectRun
class ProjectCategoryAdmin(admin.ModelAdmin): class ProjectCategoryAdmin(admin.ModelAdmin):
@ -12,8 +12,14 @@ class ProjectAdmin(admin.ModelAdmin):
class ProjectPartAdmin(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(ProjectCategory, ProjectCategoryAdmin)
admin.site.register(Project, ProjectAdmin) admin.site.register(Project, ProjectAdmin)
admin.site.register(ProjectPart, ProjectPartAdmin) admin.site.register(ProjectPart, ProjectPartAdmin)
admin.site.register(ProjectRun, ProjectRunAdmin)

View File

@ -1,4 +1,5 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from django.utils.translation import ugettext as _
from django.db import models from django.db import models
@ -16,6 +17,10 @@ class ProjectCategory(InvenTreeTree):
verbose_name = "Project Category" verbose_name = "Project Category"
verbose_name_plural = "Project Categories" verbose_name_plural = "Project Categories"
@property
def projects(self):
return self.project_set.all()
class Project(models.Model): class Project(models.Model):
""" A Project takes multiple Part objects. """ A Project takes multiple Part objects.
@ -46,18 +51,39 @@ class ProjectPart(models.Model):
OVERAGE_PERCENT = 0 OVERAGE_PERCENT = 0
OVERAGE_ABSOLUTE = 1 OVERAGE_ABSOLUTE = 1
OVARAGE_CODES = {
OVERAGE_PERCENT: _("Percent"),
OVERAGE_ABSOLUTE: _("Absolute")
}
part = models.ForeignKey(Part, on_delete=models.CASCADE) part = models.ForeignKey(Part, on_delete=models.CASCADE)
project = models.ForeignKey(Project, 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 = models.FloatField(default=0)
overage_type = models.IntegerField( overage_type = models.PositiveIntegerField(
default=1, default=OVERAGE_ABSOLUTE,
choices=[ choices=OVARAGE_CODES.items())
(OVERAGE_PERCENT, "Percent"),
(OVERAGE_ABSOLUTE, "Absolute") # Set if the part is generated by the project,
]) # rather than being consumed by the project
output = models.BooleanField(default=False)
def __str__(self): def __str__(self):
return "{quan} x {name}".format( return "{quan} x {name}".format(
name=self.part.name, name=self.part.name,
quan=self.quantity) 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. # Create your tests here.

View File

@ -3,5 +3,18 @@ from django.conf.urls import url
from . import views from . import views
urlpatterns = [ 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 rest_framework import generics
from django.http import HttpResponse
from .models import ProjectCategory, Project, ProjectPart
from .serializers import ProjectBriefSerializer, ProjectDetailSerializer
from .serializers import ProjectCategoryDetailSerializer
from .serializers import ProjectPartSerializer
def index(request): class ProjectDetail(generics.RetrieveAPIView):
return HttpResponse("This is the Projects page")
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 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') list_display = ('name', 'path', 'description')
class StockItemAdmin(admin.ModelAdmin): class StockItemAdmin(admin.ModelAdmin):
list_display = ('part', 'quantity', 'location', 'status', 'updated') list_display = ('part', 'quantity', 'location', 'status', 'updated')
admin.site.register(Warehouse, WarehouseAdmin)
admin.site.register(StockLocation, LocationAdmin)
admin.site.register(StockItem, StockItemAdmin) admin.site.register(StockItem, StockItemAdmin)

View File

@ -1,35 +1,59 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from django.utils.translation import ugettext as _
from django.db import models from django.db import models
from part.models import Part from part.models import Part
from InvenTree.models import InvenTreeTree from InvenTree.models import InvenTreeTree
class Warehouse(InvenTreeTree): class StockLocation(InvenTreeTree):
pass """ Organization tree for StockItem objects
"""
@property
def items(self):
stock_list = self.stockitem_set.all()
return stock_list
class StockItem(models.Model): class StockItem(models.Model):
part = models.ForeignKey(Part, part = models.ForeignKey(Part,
on_delete=models.CASCADE) on_delete=models.CASCADE,
location = models.ForeignKey(Warehouse, on_delete=models.CASCADE) related_name='locations')
quantity = models.IntegerField() location = models.ForeignKey(StockLocation, on_delete=models.CASCADE)
quantity = models.PositiveIntegerField()
updated = models.DateField(auto_now=True) updated = models.DateField(auto_now=True)
# Stock status types # last time the stock was checked / counted
ITEM_IN_PROGRESS = 0 last_checked = models.DateField(blank=True, null=True)
ITEM_DAMAGED = 10
ITEM_ATTENTION = 20
ITEM_COMPLETE = 50
status = models.IntegerField(default=ITEM_IN_PROGRESS, review_needed = models.BooleanField(default=False)
choices=[
(ITEM_IN_PROGRESS, "In progress"), # Stock status types
(ITEM_DAMAGED, "Damaged"), ITEM_IN_STOCK = 10
(ITEM_ATTENTION, "Requires attention"), ITEM_INCOMING = 15
(ITEM_COMPLETE, "Complete") 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): def __str__(self):
return "{n} x {part} @ {loc}".format( return "{n} x {part} @ {loc}".format(

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. # Create your tests here.

View File

@ -3,5 +3,12 @@ from django.conf.urls import url
from . import views from . import views
urlpatterns = [ 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 rest_framework import generics
from django.http import HttpResponse
from .models import Warehouse, StockItem from .models import StockLocation, StockItem
from .serializers import StockItemSerializer, LocationDetailSerializer
def index(request): class PartStockDetail(generics.ListAPIView):
""" Return a list of all stockitems for a given part
"""
warehouses = Warehouse.objects.filter(parent=None) serializer_class = StockItemSerializer
return render(request, 'stock/index.html', {'warehouses': warehouses}) 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): class CompanyAdmin(admin.ModelAdmin):
list_display = ('name', 'URL', 'contact') list_display = ('name', 'URL', 'contact')
admin.site.register(Customer, CompanyAdmin) admin.site.register(Customer, CompanyAdmin)
admin.site.register(Supplier, CompanyAdmin) admin.site.register(Supplier, CompanyAdmin)
admin.site.register(Manufacturer, CompanyAdmin) admin.site.register(Manufacturer, CompanyAdmin)

View File

@ -9,7 +9,6 @@ from part.models import Part
class Supplier(Company): class Supplier(Company):
""" Represents a manufacturer or supplier """ Represents a manufacturer or supplier
""" """
pass pass
@ -32,10 +31,8 @@ class SupplierPart(models.Model):
- A Part may be available from multiple suppliers - A Part may be available from multiple suppliers
""" """
part = models.ForeignKey(Part, part = models.ForeignKey(Part, null=True, blank=True, on_delete=models.CASCADE)
on_delete=models.CASCADE) supplier = models.ForeignKey(Supplier, on_delete=models.CASCADE)
supplier = models.ForeignKey(Supplier,
on_delete=models.CASCADE)
SKU = models.CharField(max_length=100) SKU = models.CharField(max_length=100)
manufacturer = models.ForeignKey(Manufacturer, blank=True, null=True, on_delete=models.CASCADE) manufacturer = models.ForeignKey(Manufacturer, blank=True, null=True, on_delete=models.CASCADE)
@ -44,9 +41,27 @@ class SupplierPart(models.Model):
URL = models.URLField(blank=True) URL = models.URLField(blank=True)
description = models.CharField(max_length=250, 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): def __str__(self):
return "{mpn} - {supplier}".format( return "{sku} - {supplier}".format(
mpn=self.MPN, sku=self.SKU,
supplier=self.supplier.name) supplier=self.supplier.name)
@ -56,16 +71,13 @@ class SupplierPriceBreak(models.Model):
- SupplierPart(s) may have zero-or-more associated SupplierPriceBreak(s) - SupplierPart(s) may have zero-or-more associated SupplierPriceBreak(s)
""" """
part = models.ForeignKey(SupplierPart, part = models.ForeignKey(SupplierPart, on_delete=models.CASCADE, related_name='price_breaks')
on_delete=models.CASCADE) quantity = models.PositiveIntegerField()
quantity = models.IntegerField()
cost = models.DecimalField(max_digits=10, decimal_places=3) cost = models.DecimalField(max_digits=10, decimal_places=3)
currency = models.CharField(max_length=10,
blank=True)
def __str__(self): def __str__(self):
return "{mpn} - {cost}{currency} @ {quan}".format( return "{mpn} - {cost}{currency} @ {quan}".format(
mpn=part.MPN, mpn=self.part.MPN,
cost=self.cost, cost=self.cost,
currency=self.currency if self.currency else '', currency=self.currency if self.currency else '',
quan=self.quantity) quan=self.quantity)

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,3 @@
from django.shortcuts import render, get_object_or_404
from django.http import HttpResponse 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 @@
[![Build Status](https://travis-ci.org/inventree/InvenTree.svg?branch=master)](https://travis-ci.org/inventree/InvenTree)
# InvenTree # InvenTree
Open Source Inventory Management System 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 ## Documentation
For project code documentation, refer to the online [documentation](http://inventree.readthedocs.io/en/latest/) (auto-generated) 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