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:
commit
16f3ca589b
6
.gitattributes
vendored
Normal file
6
.gitattributes
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
* text=auto
|
||||||
|
|
||||||
|
*.py text
|
||||||
|
*.md text
|
||||||
|
*.html text
|
||||||
|
*.txt text
|
12
.travis.yml
12
.travis.yml
@ -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
|
|
||||||
|
@ -1,17 +1,16 @@
|
|||||||
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
|
||||||
|
|
||||||
|
|
||||||
class Company(models.Model):
|
class Company(models.Model):
|
||||||
""" Abstract model representing an external company
|
""" Abstract model representing an external company
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
name = models.CharField(max_length=100)
|
name = models.CharField(max_length=100)
|
||||||
URL = models.URLField(blank=True)
|
URL = models.URLField(blank=True)
|
||||||
address = models.CharField(max_length=200,
|
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 has one parent Category, which can be blank (for a top-level Category).
|
||||||
- Each Category can have zero-or-more child Categor(y/ies)
|
- Each Category can have zero-or-more child Categor(y/ies)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
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.
|
||||||
If any child items are repeated, the repetitions are omitted.
|
If any child items are repeated, the repetitions are omitted.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if unique is None:
|
if unique is None:
|
||||||
unique = set()
|
unique = set()
|
||||||
|
|
||||||
if self.id in unique:
|
if self.id in unique:
|
||||||
return unique
|
return unique
|
||||||
|
|
||||||
unique.add(self.id)
|
unique.add(self.id)
|
||||||
|
|
||||||
# Some magic to get around the limitations of abstract models
|
# Some magic to get around the limitations of abstract models
|
||||||
contents = ContentType.objects.get_for_model(type(self))
|
contents = ContentType.objects.get_for_model(type(self))
|
||||||
children = contents.get_all_objects_for_this_type(parent=self.id)
|
children = contents.get_all_objects_for_this_type(parent=self.id)
|
||||||
|
|
||||||
for child in children:
|
for child in children:
|
||||||
child.getUniqueChildren(unique)
|
child.getUniqueChildren(unique)
|
||||||
|
|
||||||
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.
|
||||||
Setting the parent of an item to its own child results in recursion.
|
Setting the parent of an item to its own child results in recursion.
|
||||||
"""
|
"""
|
||||||
contents = ContentType.objects.get_for_model(type(self))
|
contents = ContentType.objects.get_for_model(type(self))
|
||||||
|
|
||||||
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]
|
||||||
|
|
||||||
for a in available:
|
for a in available:
|
||||||
if a.id not in childs:
|
if a.id not in childs:
|
||||||
acceptable.append(a)
|
acceptable.append(a)
|
||||||
|
|
||||||
return acceptable
|
return acceptable
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def parentpath(self):
|
def parentpath(self):
|
||||||
""" Return the parent path of this category
|
""" Return the parent path of this category
|
||||||
|
|
||||||
Todo:
|
Todo:
|
||||||
This function is recursive and expensive.
|
This function is recursive and expensive.
|
||||||
It should be reworked such that only a single db call is required
|
It should be reworked such that only a single db call is required
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if self.parent:
|
if self.parent:
|
||||||
return self.parent.parentpath + [self.parent]
|
return self.parent.parentpath + [self.parent]
|
||||||
else:
|
else:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def path(self):
|
def path(self):
|
||||||
if self.parent:
|
if self.parent:
|
||||||
return "/".join([p.name for p in self.parentpath]) + "/" + self.name
|
return "/".join([p.name for p in self.parentpath]) + "/" + self.name
|
||||||
else:
|
else:
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
def __setattr__(self, attrname, val):
|
def __setattr__(self, attrname, val):
|
||||||
""" Custom Attribute Setting function
|
""" Custom Attribute Setting function
|
||||||
|
|
||||||
Parent:
|
Parent:
|
||||||
Setting the parent of an item to its own child results in an infinite loop.
|
Setting the parent of an item to its own child results in an infinite loop.
|
||||||
The parent of an item cannot be set to:
|
The parent of an item cannot be set to:
|
||||||
a) Its own ID
|
a) Its own ID
|
||||||
b) The ID of any child items that exist underneath it
|
b) The ID of any child items that exist underneath it
|
||||||
|
|
||||||
Name:
|
Name:
|
||||||
Tree node names are limited to a reduced character set
|
Tree node names are limited to a reduced character set
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if attrname == 'parent_id':
|
if attrname == 'parent_id':
|
||||||
# If current ID is None, continue
|
# If current ID is None, continue
|
||||||
# - This object is just being created
|
# - This object is just being created
|
||||||
@ -140,14 +162,14 @@ class InvenTreeTree(models.Model):
|
|||||||
# Prohibit certain characters from tree node names
|
# Prohibit certain characters from tree node names
|
||||||
elif attrname == 'name':
|
elif attrname == 'name':
|
||||||
val = val.translate({ord(c): None for c in "!@#$%^&*'\"\\/[]{}<>,|+=~`"})
|
val = val.translate({ord(c): None for c in "!@#$%^&*'\"\\/[]{}<>,|+=~`"})
|
||||||
|
|
||||||
super(InvenTreeTree, self).__setattr__(attrname, val)
|
super(InvenTreeTree, self).__setattr__(attrname, val)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
""" String representation of a category is the full path to that category
|
""" String representation of a category is the full path to that category
|
||||||
|
|
||||||
Todo:
|
Todo:
|
||||||
This is recursive - Make it not so.
|
This is recursive - Make it not so.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return self.path
|
return self.path
|
||||||
|
@ -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!
|
||||||
@ -40,7 +41,7 @@ INSTALLED_APPS = [
|
|||||||
'django.contrib.sessions',
|
'django.contrib.sessions',
|
||||||
'django.contrib.messages',
|
'django.contrib.messages',
|
||||||
'django.contrib.staticfiles',
|
'django.contrib.staticfiles',
|
||||||
|
|
||||||
# InvenTree apps
|
# InvenTree apps
|
||||||
'part.apps.PartConfig',
|
'part.apps.PartConfig',
|
||||||
'project.apps.ProjectConfig',
|
'project.apps.ProjectConfig',
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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 "
|
||||||
|
@ -4,14 +4,14 @@ from .models import PartCategory, Part, PartParameter, PartParameterTemplate, Ca
|
|||||||
|
|
||||||
|
|
||||||
class PartAdmin(admin.ModelAdmin):
|
class PartAdmin(admin.ModelAdmin):
|
||||||
|
|
||||||
list_display = ('name', 'IPN', 'stock', 'category')
|
list_display = ('name', 'IPN', 'stock', 'category')
|
||||||
|
|
||||||
|
|
||||||
class PartCategoryAdmin(admin.ModelAdmin):
|
class PartCategoryAdmin(admin.ModelAdmin):
|
||||||
|
|
||||||
list_display = ('name', 'path', 'description')
|
list_display = ('name', 'path', 'description')
|
||||||
|
|
||||||
|
|
||||||
class ParameterTemplateAdmin(admin.ModelAdmin):
|
class ParameterTemplateAdmin(admin.ModelAdmin):
|
||||||
list_display = ('name', 'units', 'format')
|
list_display = ('name', 'units', 'format')
|
||||||
@ -20,7 +20,7 @@ class ParameterTemplateAdmin(admin.ModelAdmin):
|
|||||||
class ParameterAdmin(admin.ModelAdmin):
|
class ParameterAdmin(admin.ModelAdmin):
|
||||||
list_display = ('part', 'template', 'value')
|
list_display = ('part', 'template', 'value')
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(Part, PartAdmin)
|
admin.site.register(Part, PartAdmin)
|
||||||
admin.site.register(PartCategory, PartCategoryAdmin)
|
admin.site.register(PartCategory, PartCategoryAdmin)
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
|
||||||
@ -10,23 +9,42 @@ from InvenTree.models import InvenTreeTree
|
|||||||
class PartCategory(InvenTreeTree):
|
class PartCategory(InvenTreeTree):
|
||||||
""" PartCategory provides hierarchical organization of Part objects.
|
""" PartCategory provides hierarchical organization of Part objects.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
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):
|
||||||
if self.IPN:
|
if self.IPN:
|
||||||
return "{name} ({ipn})".format(
|
return "{name} ({ipn})".format(
|
||||||
@ -34,46 +52,40 @@ class Part(models.Model):
|
|||||||
name=self.name)
|
name=self.name)
|
||||||
else:
|
else:
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
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
|
||||||
|
|
||||||
result = stocks.aggregate(total=Sum('quantity'))
|
result = stocks.aggregate(total=Sum('quantity'))
|
||||||
return result['total']
|
return result['total']
|
||||||
|
|
||||||
@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()
|
||||||
project_parts = self.projectpart_set.all()
|
project_parts = self.projectpart_set.all()
|
||||||
|
|
||||||
projects = []
|
projects = []
|
||||||
|
|
||||||
for pp in project_parts:
|
for pp in project_parts:
|
||||||
if pp.project.id not in project_ids:
|
if pp.project.id not in project_ids:
|
||||||
project_ids.add(pp.project.id)
|
project_ids.add(pp.project.id)
|
||||||
projects.append(pp.project)
|
projects.append(pp.project)
|
||||||
|
|
||||||
return projects
|
return projects
|
||||||
|
|
||||||
|
|
||||||
@ -85,28 +97,31 @@ class PartParameterTemplate(models.Model):
|
|||||||
name = models.CharField(max_length=20)
|
name = models.CharField(max_length=20)
|
||||||
description = models.CharField(max_length=100, blank=True)
|
description = models.CharField(max_length=100, blank=True)
|
||||||
units = models.CharField(max_length=10, blank=True)
|
units = models.CharField(max_length=10, blank=True)
|
||||||
|
|
||||||
default_value = models.CharField(max_length=50, blank=True)
|
default_value = models.CharField(max_length=50, blank=True)
|
||||||
default_min = 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)
|
default_max = models.CharField(max_length=50, blank=True)
|
||||||
|
|
||||||
# Parameter format
|
# Parameter format
|
||||||
PARAM_NUMERIC = 10
|
PARAM_NUMERIC = 10
|
||||||
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(
|
||||||
name=self.name,
|
name=self.name,
|
||||||
units=self.units)
|
units=self.units)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Parameter Template"
|
verbose_name = "Parameter Template"
|
||||||
verbose_name_plural = "Parameter Templates"
|
verbose_name_plural = "Parameter Templates"
|
||||||
@ -117,7 +132,7 @@ class CategoryParameterLink(models.Model):
|
|||||||
"""
|
"""
|
||||||
category = models.ForeignKey(PartCategory, on_delete=models.CASCADE)
|
category = models.ForeignKey(PartCategory, on_delete=models.CASCADE)
|
||||||
template = models.ForeignKey(PartParameterTemplate, on_delete=models.CASCADE)
|
template = models.ForeignKey(PartParameterTemplate, on_delete=models.CASCADE)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "{name} - {cat}".format(
|
return "{name} - {cat}".format(
|
||||||
name=self.template.name,
|
name=self.template.name,
|
||||||
@ -126,42 +141,49 @@ class CategoryParameterLink(models.Model):
|
|||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Category Parameter"
|
verbose_name = "Category Parameter"
|
||||||
verbose_name_plural = "Category Parameters"
|
verbose_name_plural = "Category Parameters"
|
||||||
|
|
||||||
|
|
||||||
class PartParameter(models.Model):
|
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
|
||||||
value = models.CharField(max_length=50, blank=True)
|
value = models.CharField(max_length=50, blank=True)
|
||||||
min_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)
|
max_value = models.CharField(max_length=50, blank=True)
|
||||||
|
|
||||||
# Prevent multiple parameters of the same template
|
# Prevent multiple parameters of the same template
|
||||||
# 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)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "{name} : {val}{units}".format(
|
return "{name} : {val}{units}".format(
|
||||||
name=self.template.name,
|
name=self.template.name,
|
||||||
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"
|
||||||
|
|
||||||
|
|
||||||
class PartRevision(models.Model):
|
class PartRevision(models.Model):
|
||||||
""" A PartRevision represents a change-notification to a Part
|
""" A PartRevision represents a change-notification to a Part
|
||||||
@ -169,12 +191,12 @@ class PartRevision(models.Model):
|
|||||||
which should be tracked.
|
which should be tracked.
|
||||||
UniqueParts can have a single associated PartRevision
|
UniqueParts can have a single associated PartRevision
|
||||||
"""
|
"""
|
||||||
|
|
||||||
part = models.ForeignKey(Part, on_delete=models.CASCADE)
|
part = models.ForeignKey(Part, on_delete=models.CASCADE)
|
||||||
|
|
||||||
name = models.CharField(max_length=100)
|
name = models.CharField(max_length=100)
|
||||||
description = models.CharField(max_length=500)
|
description = models.CharField(max_length=500)
|
||||||
revision_date = models.DateField(auto_now_add=True)
|
revision_date = models.DateField(auto_now_add=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
@ -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')
|
||||||
|
@ -1,3 +1,3 @@
|
|||||||
from django.test import TestCase
|
# from django.test import TestCase
|
||||||
|
|
||||||
# Create your tests here.
|
# Create your tests here.
|
||||||
|
@ -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())
|
||||||
]
|
]
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
|
||||||
@ -11,24 +12,28 @@ class ProjectCategory(InvenTreeTree):
|
|||||||
Each ProjectCategory can contain zero-or-more child categories,
|
Each ProjectCategory can contain zero-or-more child categories,
|
||||||
and in turn can have zero-or-one parent category.
|
and in turn can have zero-or-one parent category.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
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.
|
||||||
A project can output zero-or-more Part objects
|
A project can output zero-or-more Part objects
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = models.CharField(max_length=100)
|
name = models.CharField(max_length=100)
|
||||||
description = models.CharField(max_length=500, blank=True)
|
description = models.CharField(max_length=500, blank=True)
|
||||||
category = models.ForeignKey(ProjectCategory, on_delete=models.CASCADE)
|
category = models.ForeignKey(ProjectCategory, on_delete=models.CASCADE)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def projectParts(self):
|
def projectParts(self):
|
||||||
""" Return a list of all project parts associated with this project
|
""" 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 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.
|
The overage is the number of extra parts that are generally used for a single run.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Overage types
|
# Overage types
|
||||||
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)
|
||||||
|
64
InvenTree/project/serializers.py
Normal file
64
InvenTree/project/serializers.py
Normal 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')
|
@ -1,3 +1,3 @@
|
|||||||
from django.test import TestCase
|
# from django.test import TestCase
|
||||||
|
|
||||||
# Create your tests here.
|
# Create your tests here.
|
||||||
|
@ -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())
|
||||||
]
|
]
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
@ -1,36 +1,60 @@
|
|||||||
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)
|
||||||
|
|
||||||
|
# last time the stock was checked / counted
|
||||||
|
last_checked = models.DateField(blank=True, null=True)
|
||||||
|
|
||||||
|
review_needed = models.BooleanField(default=False)
|
||||||
|
|
||||||
# Stock status types
|
# Stock status types
|
||||||
ITEM_IN_PROGRESS = 0
|
ITEM_IN_STOCK = 10
|
||||||
ITEM_DAMAGED = 10
|
ITEM_INCOMING = 15
|
||||||
ITEM_ATTENTION = 20
|
ITEM_IN_PROGRESS = 20
|
||||||
ITEM_COMPLETE = 50
|
ITEM_COMPLETE = 25
|
||||||
|
ITEM_ATTENTION = 50
|
||||||
status = models.IntegerField(default=ITEM_IN_PROGRESS,
|
ITEM_DAMAGED = 55
|
||||||
choices=[
|
ITEM_DESTROYED = 60
|
||||||
(ITEM_IN_PROGRESS, "In progress"),
|
|
||||||
(ITEM_DAMAGED, "Damaged"),
|
ITEM_STATUS_CODES = {
|
||||||
(ITEM_ATTENTION, "Requires attention"),
|
ITEM_IN_STOCK: _("In stock"),
|
||||||
(ITEM_COMPLETE, "Complete")
|
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(
|
||||||
n=self.quantity,
|
n=self.quantity,
|
||||||
|
52
InvenTree/stock/serializers.py
Normal file
52
InvenTree/stock/serializers.py
Normal 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')
|
@ -1,3 +1,3 @@
|
|||||||
from django.test import TestCase
|
# from django.test import TestCase
|
||||||
|
|
||||||
# Create your tests here.
|
# Create your tests here.
|
||||||
|
@ -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())
|
||||||
]
|
]
|
||||||
|
@ -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)
|
"""
|
||||||
|
|
||||||
return render(request, 'stock/index.html', {'warehouses': warehouses})
|
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
|
||||||
|
@ -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)
|
||||||
|
@ -9,9 +9,8 @@ from part.models import Part
|
|||||||
class Supplier(Company):
|
class Supplier(Company):
|
||||||
""" Represents a manufacturer or supplier
|
""" Represents a manufacturer or supplier
|
||||||
"""
|
"""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class Manufacturer(Company):
|
class Manufacturer(Company):
|
||||||
""" Represents a manfufacturer
|
""" Represents a manfufacturer
|
||||||
@ -32,21 +31,37 @@ 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)
|
||||||
MPN = models.CharField(max_length=100, blank=True)
|
MPN = models.CharField(max_length=100, blank=True)
|
||||||
|
|
||||||
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)
|
||||||
|
@ -1,3 +1,3 @@
|
|||||||
from django.test import TestCase
|
# from django.test import TestCase
|
||||||
|
|
||||||
# Create your tests here.
|
# Create your tests here.
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
@ -1,3 +1,3 @@
|
|||||||
from django.test import TestCase
|
# from django.test import TestCase
|
||||||
|
|
||||||
# Create your tests here.
|
# Create your tests here.
|
||||||
|
@ -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
34
Makefile
Normal 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
|
24
README.md
24
README.md
@ -1,5 +1,25 @@
|
|||||||
# InvenTree
|
[](https://travis-ci.org/inventree/InvenTree)
|
||||||
Open Source Inventory Management System
|
|
||||||
|
# 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
|
## 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`.
|
||||||
|
44
install.py
44
install.py
@ -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
2
requirements/base.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
Django==1.11
|
||||||
|
djangorestframework==3.6.2
|
2
requirements/build.txt
Normal file
2
requirements/build.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
-r base.txt
|
||||||
|
flake8==3.3.0
|
4
requirements/dev.txt
Normal file
4
requirements/dev.txt
Normal 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
28
roadmap.md
Normal 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
|
Loading…
x
Reference in New Issue
Block a user