diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..db355084a6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +* text=auto + +*.py text +*.md text +*.html text +*.txt text diff --git a/.travis.yml b/.travis.yml index 2ee970d740..9f63351f98 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,11 +3,9 @@ python: - 3.4 before_install: - - pip install pep8 - - pip install django - - pip install djangorestframework - + - make setup + - make setup_ci + script: - - "pep8 --exclude=migrations --ignore=E402,W293,E501 InvenTree" - - python InvenTree/manage.py check - - python InvenTree/manage.py test --noinput \ No newline at end of file + - make style + - make test diff --git a/InvenTree/InvenTree/models.py b/InvenTree/InvenTree/models.py index 81bf2c62db..4eb8dd8daa 100644 --- a/InvenTree/InvenTree/models.py +++ b/InvenTree/InvenTree/models.py @@ -1,17 +1,16 @@ from __future__ import unicode_literals from django.db import models -from django.core.exceptions import ObjectDoesNotExist from django.contrib.contenttypes.models import ContentType class Company(models.Model): """ Abstract model representing an external company """ - + class Meta: abstract = True - + name = models.CharField(max_length=100) URL = models.URLField(blank=True) address = models.CharField(max_length=200, @@ -33,93 +32,116 @@ class InvenTreeTree(models.Model): - Each Category has one parent Category, which can be blank (for a top-level Category). - Each Category can have zero-or-more child Categor(y/ies) """ - + class Meta: abstract = True - + name = models.CharField(max_length=100) - description = models.CharField(max_length=250) + description = models.CharField(max_length=250, blank=True) parent = models.ForeignKey('self', on_delete=models.CASCADE, blank=True, - null=True) - + null=True, + related_name='children') + + def getUniqueParents(self, unique=None): + """ Return a flat set of all parent items that exist above this node. + If any parents are repeated (which would be very bad!), the process is halted + """ + + if unique is None: + unique = set() + else: + unique.add(self.id) + + if self.parent and self.parent.id not in unique: + self.parent.getUniqueParents(unique) + + return unique + def getUniqueChildren(self, unique=None): """ Return a flat set of all child items that exist under this node. If any child items are repeated, the repetitions are omitted. """ - + if unique is None: unique = set() - + if self.id in unique: return unique - + unique.add(self.id) - + # Some magic to get around the limitations of abstract models contents = ContentType.objects.get_for_model(type(self)) children = contents.get_all_objects_for_this_type(parent=self.id) - + for child in children: child.getUniqueChildren(unique) - + return unique - + + @property + def children(self): + contents = ContentType.objects.get_for_model(type(self)) + children = contents.get_all_objects_for_this_type(parent=self.id) + + return children + def getAcceptableParents(self): """ Returns a list of acceptable parent items within this model Acceptable parents are ones which are not underneath this item. Setting the parent of an item to its own child results in recursion. """ contents = ContentType.objects.get_for_model(type(self)) - + available = contents.get_all_objects_for_this_type() - + # List of child IDs - childs = getUniqueChildren() - + childs = self.getUniqueChildren() + acceptable = [None] - + for a in available: if a.id not in childs: acceptable.append(a) - + return acceptable - + @property def parentpath(self): """ Return the parent path of this category - + Todo: This function is recursive and expensive. It should be reworked such that only a single db call is required """ - + if self.parent: return self.parent.parentpath + [self.parent] else: return [] - + @property def path(self): if self.parent: return "/".join([p.name for p in self.parentpath]) + "/" + self.name else: return self.name - + def __setattr__(self, attrname, val): """ Custom Attribute Setting function - + Parent: Setting the parent of an item to its own child results in an infinite loop. The parent of an item cannot be set to: a) Its own ID b) The ID of any child items that exist underneath it - + Name: Tree node names are limited to a reduced character set """ - + if attrname == 'parent_id': # If current ID is None, continue # - This object is just being created @@ -140,14 +162,14 @@ class InvenTreeTree(models.Model): # Prohibit certain characters from tree node names elif attrname == 'name': val = val.translate({ord(c): None for c in "!@#$%^&*'\"\\/[]{}<>,|+=~`"}) - + super(InvenTreeTree, self).__setattr__(attrname, val) def __str__(self): """ String representation of a category is the full path to that category - + Todo: This is recursive - Make it not so. """ - + return self.path diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 8585f5b911..ab2205a296 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -20,6 +20,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! +# TODO: remove this SECRET_KEY = 'oc2z%5)lu#jsxi#wpg)700z@v48)2aa_yn(a(3qg!z!fw&tr9f' # SECURITY WARNING: don't run with debug turned on in production! @@ -40,7 +41,7 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', - + # InvenTree apps 'part.apps.PartConfig', 'project.apps.ProjectConfig', diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index 52b69c4a95..f4a6b74667 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -1,19 +1,3 @@ -"""InvenTree URL Configuration - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/1.10/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') -Including another URLconf - 1. Import the include() function: from django.conf.urls import url, include - 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) -""" - from django.conf.urls import url, include from django.contrib import admin diff --git a/InvenTree/manage.py b/InvenTree/manage.py index cfaeab5f9e..dc8d382fac 100755 --- a/InvenTree/manage.py +++ b/InvenTree/manage.py @@ -11,7 +11,7 @@ if __name__ == "__main__": # issue is really that Django is missing to avoid masking other # exceptions on Python 2. try: - import django + import django # NOQA except ImportError: raise ImportError( "Couldn't import Django. Are you sure it's installed and " diff --git a/InvenTree/part/admin.py b/InvenTree/part/admin.py index f36f4f8931..dd5ec73763 100644 --- a/InvenTree/part/admin.py +++ b/InvenTree/part/admin.py @@ -4,14 +4,14 @@ from .models import PartCategory, Part, PartParameter, PartParameterTemplate, Ca class PartAdmin(admin.ModelAdmin): - + list_display = ('name', 'IPN', 'stock', 'category') class PartCategoryAdmin(admin.ModelAdmin): - + list_display = ('name', 'path', 'description') - + class ParameterTemplateAdmin(admin.ModelAdmin): list_display = ('name', 'units', 'format') @@ -20,7 +20,7 @@ class ParameterTemplateAdmin(admin.ModelAdmin): class ParameterAdmin(admin.ModelAdmin): list_display = ('part', 'template', 'value') - + admin.site.register(Part, PartAdmin) admin.site.register(PartCategory, PartCategoryAdmin) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 863d2c44a4..c479037743 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -1,8 +1,7 @@ from __future__ import unicode_literals - +from django.utils.translation import ugettext as _ from django.db import models from django.db.models import Sum -from django.core.exceptions import ObjectDoesNotExist, ValidationError from InvenTree.models import InvenTreeTree @@ -10,23 +9,42 @@ from InvenTree.models import InvenTreeTree class PartCategory(InvenTreeTree): """ PartCategory provides hierarchical organization of Part objects. """ - + class Meta: verbose_name = "Part Category" verbose_name_plural = "Part Categories" - - + + @property + def parts(self): + return self.part_set.all() + + class Part(models.Model): """ Represents a """ - + + # Short name of the part name = models.CharField(max_length=100) + + # Longer description of the part (optional) description = models.CharField(max_length=250, blank=True) + + # Internal Part Number (optional) IPN = models.CharField(max_length=100, blank=True) + + # Part category - all parts must be assigned to a category category = models.ForeignKey(PartCategory, on_delete=models.CASCADE) - minimum_stock = models.IntegerField(default=0) + + # Minimum "allowed" stock level + minimum_stock = models.PositiveIntegerField(default=0) + + # Units of quantity for this part. Default is "pcs" units = models.CharField(max_length=20, default="pcs", blank=True) + + # Is this part "trackable"? + # Trackable parts can have unique instances which are assigned serial numbers + # and can have their movements tracked trackable = models.BooleanField(default=False) - + def __str__(self): if self.IPN: return "{name} ({ipn})".format( @@ -34,46 +52,40 @@ class Part(models.Model): name=self.name) else: return self.name - + class Meta: verbose_name = "Part" verbose_name_plural = "Parts" - - @property - def stock_list(self): - """ Return a list of all stock objects associated with this part - """ - - return self.stockitem_set.all() - + @property def stock(self): """ Return the total stock quantity for this part. Part may be stored in multiple locations """ - - stocks = self.stock_list + + stocks = self.locations.all() if len(stocks) == 0: return 0 - + result = stocks.aggregate(total=Sum('quantity')) return result['total'] - + @property def projects(self): - """ Return a list of unique projects that this part is associated with + """ Return a list of unique projects that this part is associated with. + A part may be used in zero or more projects. """ - + project_ids = set() project_parts = self.projectpart_set.all() - + projects = [] - + for pp in project_parts: if pp.project.id not in project_ids: project_ids.add(pp.project.id) projects.append(pp.project) - + return projects @@ -85,28 +97,31 @@ class PartParameterTemplate(models.Model): name = models.CharField(max_length=20) description = models.CharField(max_length=100, blank=True) units = models.CharField(max_length=10, blank=True) - + default_value = models.CharField(max_length=50, blank=True) default_min = models.CharField(max_length=50, blank=True) default_max = models.CharField(max_length=50, blank=True) - + # Parameter format PARAM_NUMERIC = 10 PARAM_TEXT = 20 PARAM_BOOL = 30 - - format = models.IntegerField( + + PARAM_TYPE_CODES = { + PARAM_NUMERIC: _("Numeric"), + PARAM_TEXT: _("Text"), + PARAM_BOOL: _("Bool") + } + + format = models.PositiveIntegerField( default=PARAM_NUMERIC, - choices=[ - (PARAM_NUMERIC, "Numeric"), - (PARAM_TEXT, "Text"), - (PARAM_BOOL, "Boolean")]) - + choices=PARAM_TYPE_CODES.items()) + def __str__(self): return "{name} ({units})".format( name=self.name, units=self.units) - + class Meta: verbose_name = "Parameter Template" verbose_name_plural = "Parameter Templates" @@ -117,7 +132,7 @@ class CategoryParameterLink(models.Model): """ category = models.ForeignKey(PartCategory, on_delete=models.CASCADE) template = models.ForeignKey(PartParameterTemplate, on_delete=models.CASCADE) - + def __str__(self): return "{name} - {cat}".format( name=self.template.name, @@ -126,42 +141,49 @@ class CategoryParameterLink(models.Model): class Meta: verbose_name = "Category Parameter" verbose_name_plural = "Category Parameters" - + class PartParameter(models.Model): """ PartParameter is associated with a single part """ - - part = models.ForeignKey(Part, on_delete=models.CASCADE) - + + part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='parameters') template = models.ForeignKey(PartParameterTemplate) - + # Value data value = models.CharField(max_length=50, blank=True) min_value = models.CharField(max_length=50, blank=True) max_value = models.CharField(max_length=50, blank=True) - + # Prevent multiple parameters of the same template # from being added to the same part def save(self, *args, **kwargs): params = PartParameter.objects.filter(part=self.part, template=self.template) - if len(params) > 0: - raise ValidationError("Parameter '{param}' already exists for {part}".format( - param=self.template.name, - part=self.part.name)) - + if len(params) > 1: + return + if len(params) == 1 and params[0].id != self.id: + return + super(PartParameter, self).save(*args, **kwargs) - + def __str__(self): return "{name} : {val}{units}".format( name=self.template.name, val=self.value, units=self.template.units) - + + @property + def units(self): + return self.template.units + + @property + def name(self): + return self.template.name + class Meta: verbose_name = "Part Parameter" verbose_name_plural = "Part Parameters" - + class PartRevision(models.Model): """ A PartRevision represents a change-notification to a Part @@ -169,12 +191,12 @@ class PartRevision(models.Model): which should be tracked. UniqueParts can have a single associated PartRevision """ - + part = models.ForeignKey(Part, on_delete=models.CASCADE) - + name = models.CharField(max_length=100) description = models.CharField(max_length=500) revision_date = models.DateField(auto_now_add=True) - + def __str__(self): return self.name diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index b15ea800c6..1ff66264e3 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -1,22 +1,60 @@ from rest_framework import serializers -from .models import Part, PartCategory +from .models import Part, PartCategory, PartParameter + + +class PartParameterSerializer(serializers.ModelSerializer): + """ Serializer for a PartParameter + """ + + class Meta: + model = PartParameter + fields = ('pk', + 'part', + 'template', + 'name', + 'value', + 'units') class PartSerializer(serializers.ModelSerializer): + """ Serializer for complete detail information of a part. + Used when displaying all details of a single component. + """ + class Meta: model = Part fields = ('pk', + 'name', 'IPN', 'description', 'category', 'stock') - - -class PartCategorySerializer(serializers.ModelSerializer): + + +class PartCategoryBriefSerializer(serializers.ModelSerializer): + + class Meta: + model = PartCategory + fields = ('pk', + 'name', + 'description') + + +class PartCategoryDetailSerializer(serializers.ModelSerializer): + + # List of parts in this category + parts = PartSerializer(many=True) + + # List of child categories under this one + children = PartCategoryBriefSerializer(many=True) + class Meta: model = PartCategory fields = ('pk', 'name', 'description', - 'path') + 'parent', + 'path', + 'children', + 'parts') diff --git a/InvenTree/part/tests.py b/InvenTree/part/tests.py index 7ce503c2dd..a79ca8be56 100644 --- a/InvenTree/part/tests.py +++ b/InvenTree/part/tests.py @@ -1,3 +1,3 @@ -from django.test import TestCase +# from django.test import TestCase # Create your tests here. diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index 01ebb11cbf..de6c9c13a8 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -3,15 +3,18 @@ from django.conf.urls import url from . import views urlpatterns = [ - # Display part detail + # Single part detail url(r'^(?P[0-9]+)/$', views.PartDetail.as_view()), - - # Display a single part category + + # Part parameters list + url(r'^(?P[0-9]+)/parameters/$', views.PartParameters.as_view()), + + # Part category detail url(r'^category/(?P[0-9]+)/$', views.PartCategoryDetail.as_view()), - - # Display a list of top-level categories + + # List of top-level categories url(r'^category/$', views.PartCategoryList.as_view()), - - # Display list of parts + + # List of all parts url(r'^$', views.PartList.as_view()) ] diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 28d447303e..dcd5ac54a3 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -1,14 +1,9 @@ -from django.shortcuts import render, get_object_or_404 -from django.http import HttpResponse, Http404 - from rest_framework import generics -from .models import PartCategory, Part -from .serializers import PartSerializer, PartCategorySerializer - - -def index(request): - return HttpResponse("Hello world. This is the parts page") +from .models import PartCategory, Part, PartParameter +from .serializers import PartSerializer +from .serializers import PartCategoryDetailSerializer +from .serializers import PartParameterSerializer class PartDetail(generics.RetrieveAPIView): @@ -17,6 +12,15 @@ class PartDetail(generics.RetrieveAPIView): serializer_class = PartSerializer +class PartParameters(generics.ListAPIView): + + def get_queryset(self): + part_id = self.kwargs['pk'] + return PartParameter.objects.filter(part=part_id) + + serializer_class = PartParameterSerializer + + class PartList(generics.ListAPIView): queryset = Part.objects.all() @@ -24,12 +28,15 @@ class PartList(generics.ListAPIView): class PartCategoryDetail(generics.RetrieveAPIView): - + """ Return information on a single PartCategory + """ queryset = PartCategory.objects.all() - serializer_class = PartCategorySerializer + serializer_class = PartCategoryDetailSerializer class PartCategoryList(generics.ListAPIView): - - queryset = PartCategory.objects.all() - serializer_class = PartCategorySerializer + """ Return a list of all top-level part categories. + Categories are considered "top-level" if they do not have a parent + """ + queryset = PartCategory.objects.filter(parent=None) + serializer_class = PartCategoryDetailSerializer diff --git a/InvenTree/project/admin.py b/InvenTree/project/admin.py index b37fa8435c..9224385d9a 100644 --- a/InvenTree/project/admin.py +++ b/InvenTree/project/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from .models import ProjectCategory, Project, ProjectPart +from .models import ProjectCategory, Project, ProjectPart, ProjectRun class ProjectCategoryAdmin(admin.ModelAdmin): @@ -12,8 +12,14 @@ class ProjectAdmin(admin.ModelAdmin): class ProjectPartAdmin(admin.ModelAdmin): - list_display = ('part', 'project', 'quantity') + list_display = ('part', 'project', 'quantity', 'output') + + +class ProjectRunAdmin(admin.ModelAdmin): + list_display = ('project', 'quantity', 'run_date') + admin.site.register(ProjectCategory, ProjectCategoryAdmin) admin.site.register(Project, ProjectAdmin) admin.site.register(ProjectPart, ProjectPartAdmin) +admin.site.register(ProjectRun, ProjectRunAdmin) diff --git a/InvenTree/project/models.py b/InvenTree/project/models.py index a46b641d39..1958540699 100644 --- a/InvenTree/project/models.py +++ b/InvenTree/project/models.py @@ -1,4 +1,5 @@ from __future__ import unicode_literals +from django.utils.translation import ugettext as _ from django.db import models @@ -11,24 +12,28 @@ class ProjectCategory(InvenTreeTree): Each ProjectCategory can contain zero-or-more child categories, and in turn can have zero-or-one parent category. """ - + class Meta: verbose_name = "Project Category" verbose_name_plural = "Project Categories" + @property + def projects(self): + return self.project_set.all() + class Project(models.Model): """ A Project takes multiple Part objects. A project can output zero-or-more Part objects """ - + name = models.CharField(max_length=100) description = models.CharField(max_length=500, blank=True) category = models.ForeignKey(ProjectCategory, on_delete=models.CASCADE) - + def __str__(self): return self.name - + @property def projectParts(self): """ Return a list of all project parts associated with this project @@ -41,23 +46,44 @@ class ProjectPart(models.Model): The quantity of parts required for a single-run of that project is stored. The overage is the number of extra parts that are generally used for a single run. """ - + # Overage types OVERAGE_PERCENT = 0 OVERAGE_ABSOLUTE = 1 - + + OVARAGE_CODES = { + OVERAGE_PERCENT: _("Percent"), + OVERAGE_ABSOLUTE: _("Absolute") + } + part = models.ForeignKey(Part, on_delete=models.CASCADE) project = models.ForeignKey(Project, on_delete=models.CASCADE) - quantity = models.IntegerField(default=1) + quantity = models.PositiveIntegerField(default=1) overage = models.FloatField(default=0) - overage_type = models.IntegerField( - default=1, - choices=[ - (OVERAGE_PERCENT, "Percent"), - (OVERAGE_ABSOLUTE, "Absolute") - ]) - + overage_type = models.PositiveIntegerField( + default=OVERAGE_ABSOLUTE, + choices=OVARAGE_CODES.items()) + + # Set if the part is generated by the project, + # rather than being consumed by the project + output = models.BooleanField(default=False) + def __str__(self): return "{quan} x {name}".format( name=self.part.name, quan=self.quantity) + + +class ProjectRun(models.Model): + """ A single run of a particular project. + Tracks the number of 'units' made in the project. + Provides functionality to update stock, + based on both: + a) Parts used (project inputs) + b) Parts produced (project outputs) + """ + + project = models.ForeignKey(Project, on_delete=models.CASCADE) + quantity = models.PositiveIntegerField(default=1) + + run_date = models.DateField(auto_now_add=True) diff --git a/InvenTree/project/serializers.py b/InvenTree/project/serializers.py new file mode 100644 index 0000000000..0dae3f7cbd --- /dev/null +++ b/InvenTree/project/serializers.py @@ -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') diff --git a/InvenTree/project/tests.py b/InvenTree/project/tests.py index 7ce503c2dd..a79ca8be56 100644 --- a/InvenTree/project/tests.py +++ b/InvenTree/project/tests.py @@ -1,3 +1,3 @@ -from django.test import TestCase +# from django.test import TestCase # Create your tests here. diff --git a/InvenTree/project/urls.py b/InvenTree/project/urls.py index 9cb3403af4..2758a64cc8 100644 --- a/InvenTree/project/urls.py +++ b/InvenTree/project/urls.py @@ -3,5 +3,18 @@ from django.conf.urls import url from . import views urlpatterns = [ - url(r'^$', views.index, name='index') + # Single project detail + url(r'^(?P[0-9]+)/$', views.ProjectDetail.as_view()), + + # Parts associated with a project + url(r'^(?P[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[0-9]+)/$', views.ProjectCategoryDetail.as_view()) ] diff --git a/InvenTree/project/views.py b/InvenTree/project/views.py index a9b40c0cc5..46a5fdb0c4 100644 --- a/InvenTree/project/views.py +++ b/InvenTree/project/views.py @@ -1,6 +1,39 @@ -from django.shortcuts import render, get_object_or_404 -from django.http import HttpResponse +from rest_framework import generics + +from .models import ProjectCategory, Project, ProjectPart +from .serializers import ProjectBriefSerializer, ProjectDetailSerializer +from .serializers import ProjectCategoryDetailSerializer +from .serializers import ProjectPartSerializer -def index(request): - return HttpResponse("This is the Projects page") +class ProjectDetail(generics.RetrieveAPIView): + + queryset = Project.objects.all() + serializer_class = ProjectDetailSerializer + + +class ProjectList(generics.ListAPIView): + + queryset = Project.objects.all() + serializer_class = ProjectBriefSerializer + + +class ProjectCategoryDetail(generics.RetrieveAPIView): + + queryset = ProjectCategory.objects.all() + serializer_class = ProjectCategoryDetailSerializer + + +class ProjectCategoryList(generics.ListAPIView): + + queryset = ProjectCategory.objects.filter(parent=None) + serializer_class = ProjectCategoryDetailSerializer + + +class ProjectPartsList(generics.ListAPIView): + + serializer_class = ProjectPartSerializer + + def get_queryset(self): + project_id = self.kwargs['pk'] + return ProjectPart.objects.filter(project=project_id) diff --git a/InvenTree/stock/admin.py b/InvenTree/stock/admin.py index 94de179851..6ce4df95c5 100644 --- a/InvenTree/stock/admin.py +++ b/InvenTree/stock/admin.py @@ -1,14 +1,15 @@ from django.contrib import admin -from .models import Warehouse, StockItem +from .models import StockLocation, StockItem -class WarehouseAdmin(admin.ModelAdmin): +class LocationAdmin(admin.ModelAdmin): list_display = ('name', 'path', 'description') class StockItemAdmin(admin.ModelAdmin): list_display = ('part', 'quantity', 'location', 'status', 'updated') -admin.site.register(Warehouse, WarehouseAdmin) + +admin.site.register(StockLocation, LocationAdmin) admin.site.register(StockItem, StockItemAdmin) diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index f72a0f3d3a..dc5f412420 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -1,36 +1,60 @@ from __future__ import unicode_literals - +from django.utils.translation import ugettext as _ from django.db import models from part.models import Part from InvenTree.models import InvenTreeTree -class Warehouse(InvenTreeTree): - pass - +class StockLocation(InvenTreeTree): + """ Organization tree for StockItem objects + """ + + @property + def items(self): + stock_list = self.stockitem_set.all() + return stock_list + class StockItem(models.Model): part = models.ForeignKey(Part, - on_delete=models.CASCADE) - location = models.ForeignKey(Warehouse, on_delete=models.CASCADE) - quantity = models.IntegerField() + on_delete=models.CASCADE, + related_name='locations') + location = models.ForeignKey(StockLocation, on_delete=models.CASCADE) + quantity = models.PositiveIntegerField() updated = models.DateField(auto_now=True) - + + # last time the stock was checked / counted + last_checked = models.DateField(blank=True, null=True) + + review_needed = models.BooleanField(default=False) + # Stock status types - ITEM_IN_PROGRESS = 0 - ITEM_DAMAGED = 10 - ITEM_ATTENTION = 20 - ITEM_COMPLETE = 50 - - status = models.IntegerField(default=ITEM_IN_PROGRESS, - choices=[ - (ITEM_IN_PROGRESS, "In progress"), - (ITEM_DAMAGED, "Damaged"), - (ITEM_ATTENTION, "Requires attention"), - (ITEM_COMPLETE, "Complete") - ]) - + ITEM_IN_STOCK = 10 + ITEM_INCOMING = 15 + ITEM_IN_PROGRESS = 20 + ITEM_COMPLETE = 25 + ITEM_ATTENTION = 50 + ITEM_DAMAGED = 55 + ITEM_DESTROYED = 60 + + ITEM_STATUS_CODES = { + ITEM_IN_STOCK: _("In stock"), + ITEM_INCOMING: _("Incoming"), + ITEM_IN_PROGRESS: _("In progress"), + ITEM_COMPLETE: _("Complete"), + ITEM_ATTENTION: _("Attention needed"), + ITEM_DAMAGED: _("Damaged"), + ITEM_DESTROYED: _("Destroyed") + } + + status = models.PositiveIntegerField( + default=ITEM_IN_STOCK, + choices=ITEM_STATUS_CODES.items()) + + # If stock item is incoming, an (optional) ETA field + expected_arrival = models.DateField(null=True, blank=True) + def __str__(self): return "{n} x {part} @ {loc}".format( n=self.quantity, diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py new file mode 100644 index 0000000000..bf07dfa1de --- /dev/null +++ b/InvenTree/stock/serializers.py @@ -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') diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py index 7ce503c2dd..a79ca8be56 100644 --- a/InvenTree/stock/tests.py +++ b/InvenTree/stock/tests.py @@ -1,3 +1,3 @@ -from django.test import TestCase +# from django.test import TestCase # Create your tests here. diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index 9cb3403af4..a9dc1bc09e 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -3,5 +3,12 @@ from django.conf.urls import url from . import views urlpatterns = [ - url(r'^$', views.index, name='index') + # List all stock quantities for a given part + url(r'^part/(?P[0-9]+)$', views.PartStockDetail.as_view()), + + # List all stock items in a given location + url(r'^location/(?P[0-9]+)$', views.LocationDetail.as_view()), + + # List all top-level locations + url(r'^location/$', views.LocationList.as_view()) ] diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 394fe97ad0..5f76cbebf1 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -1,11 +1,33 @@ -from django.shortcuts import render, get_object_or_404 -from django.http import HttpResponse +from rest_framework import generics -from .models import Warehouse, StockItem +from .models import StockLocation, StockItem + +from .serializers import StockItemSerializer, LocationDetailSerializer -def index(request): - - warehouses = Warehouse.objects.filter(parent=None) - - return render(request, 'stock/index.html', {'warehouses': warehouses}) +class PartStockDetail(generics.ListAPIView): + """ Return a list of all stockitems for a given part + """ + + serializer_class = StockItemSerializer + + def get_queryset(self): + part_id = self.kwargs['part'] + return StockItem.objects.filter(part=part_id) + + +class LocationDetail(generics.RetrieveAPIView): + """ Return information on a specific stock location + """ + + queryset = StockLocation.objects.all() + serializer_class = LocationDetailSerializer + + +class LocationList(generics.ListAPIView): + """ Return a list of top-level locations + Locations are considered "top-level" if they do not have a parent + """ + + queryset = StockLocation.objects.filter(parent=None) + serializer_class = LocationDetailSerializer diff --git a/InvenTree/supplier/admin.py b/InvenTree/supplier/admin.py index 9e9d53dfa4..cf19edd2b0 100644 --- a/InvenTree/supplier/admin.py +++ b/InvenTree/supplier/admin.py @@ -6,6 +6,7 @@ from .models import Supplier, SupplierPart, Customer, Manufacturer class CompanyAdmin(admin.ModelAdmin): list_display = ('name', 'URL', 'contact') + admin.site.register(Customer, CompanyAdmin) admin.site.register(Supplier, CompanyAdmin) admin.site.register(Manufacturer, CompanyAdmin) diff --git a/InvenTree/supplier/models.py b/InvenTree/supplier/models.py index db4f8a6659..bd5d20dc86 100644 --- a/InvenTree/supplier/models.py +++ b/InvenTree/supplier/models.py @@ -9,9 +9,8 @@ from part.models import Part class Supplier(Company): """ Represents a manufacturer or supplier """ - pass - + class Manufacturer(Company): """ Represents a manfufacturer @@ -32,21 +31,37 @@ class SupplierPart(models.Model): - A Part may be available from multiple suppliers """ - part = models.ForeignKey(Part, - on_delete=models.CASCADE) - supplier = models.ForeignKey(Supplier, - on_delete=models.CASCADE) + part = models.ForeignKey(Part, null=True, blank=True, on_delete=models.CASCADE) + supplier = models.ForeignKey(Supplier, on_delete=models.CASCADE) SKU = models.CharField(max_length=100) - + manufacturer = models.ForeignKey(Manufacturer, blank=True, null=True, on_delete=models.CASCADE) MPN = models.CharField(max_length=100, blank=True) - + URL = models.URLField(blank=True) description = models.CharField(max_length=250, blank=True) + # Default price for a single unit + single_price = models.DecimalField(max_digits=10, decimal_places=3, default=0) + + # Base charge added to order independent of quantity e.g. "Reeling Fee" + base_cost = models.DecimalField(max_digits=10, decimal_places=3, default=0) + + # packaging that the part is supplied in, e.g. "Reel" + packaging = models.CharField(max_length=50, blank=True) + + # multiple that the part is provided in + multiple = models.PositiveIntegerField(default=1) + + # Mimumum number required to order + minimum = models.PositiveIntegerField(default=1) + + # lead time for parts that cannot be delivered immediately + lead_time = models.DurationField(blank=True, null=True) + def __str__(self): - return "{mpn} - {supplier}".format( - mpn=self.MPN, + return "{sku} - {supplier}".format( + sku=self.SKU, supplier=self.supplier.name) @@ -56,16 +71,13 @@ class SupplierPriceBreak(models.Model): - SupplierPart(s) may have zero-or-more associated SupplierPriceBreak(s) """ - part = models.ForeignKey(SupplierPart, - on_delete=models.CASCADE) - quantity = models.IntegerField() + part = models.ForeignKey(SupplierPart, on_delete=models.CASCADE, related_name='price_breaks') + quantity = models.PositiveIntegerField() cost = models.DecimalField(max_digits=10, decimal_places=3) - currency = models.CharField(max_length=10, - blank=True) def __str__(self): return "{mpn} - {cost}{currency} @ {quan}".format( - mpn=part.MPN, + mpn=self.part.MPN, cost=self.cost, currency=self.currency if self.currency else '', quan=self.quantity) diff --git a/InvenTree/supplier/tests.py b/InvenTree/supplier/tests.py index 7ce503c2dd..a79ca8be56 100644 --- a/InvenTree/supplier/tests.py +++ b/InvenTree/supplier/tests.py @@ -1,3 +1,3 @@ -from django.test import TestCase +# from django.test import TestCase # Create your tests here. diff --git a/InvenTree/track/admin.py b/InvenTree/track/admin.py index ac6cf4ca7f..573e458878 100644 --- a/InvenTree/track/admin.py +++ b/InvenTree/track/admin.py @@ -4,6 +4,7 @@ from .models import UniquePart class UniquePartAdmin(admin.ModelAdmin): - list_display = ('part', 'revision', 'serial', 'creation_date') + list_display = ('part', 'revision', 'serial', 'status', 'creation_date') + admin.site.register(UniquePart, UniquePartAdmin) diff --git a/InvenTree/track/models.py b/InvenTree/track/models.py index 03d159c31e..4fed0fdd60 100644 --- a/InvenTree/track/models.py +++ b/InvenTree/track/models.py @@ -1,5 +1,5 @@ from __future__ import unicode_literals - +from django.utils.translation import ugettext as _ from django.db import models from django.contrib.auth.models import User @@ -36,15 +36,16 @@ class UniquePart(models.Model): PART_DAMAGED = 40 PART_DESTROYED = 50 - status = models.IntegerField(default=PART_IN_PROGRESS, - choices=[ - (PART_IN_PROGRESS, "In progress"), - (PART_IN_STOCK, "In stock"), - (PART_SHIPPED, "Shipped"), - (PART_RETURNED, "Returned"), - (PART_DAMAGED, "Damaged"), - (PART_DESTROYED, "Destroyed"), - ]) + PART_STATUS_CODES = { + PART_IN_PROGRESS: _("In progress"), + PART_IN_STOCK: _("In stock"), + PART_SHIPPED: _("Shipped"), + PART_RETURNED: _("Returned"), + PART_DAMAGED: _("Damaged"), + PART_DESTROYED: _("Destroyed") + } + + status = models.IntegerField(default=PART_IN_PROGRESS, choices=PART_STATUS_CODES.items()) def __str__(self): return self.part.name diff --git a/InvenTree/track/tests.py b/InvenTree/track/tests.py index 7ce503c2dd..a79ca8be56 100644 --- a/InvenTree/track/tests.py +++ b/InvenTree/track/tests.py @@ -1,3 +1,3 @@ -from django.test import TestCase +# from django.test import TestCase # Create your tests here. diff --git a/InvenTree/track/views.py b/InvenTree/track/views.py index 5986b94af1..0a26c7bf12 100644 --- a/InvenTree/track/views.py +++ b/InvenTree/track/views.py @@ -1,4 +1,3 @@ -from django.shortcuts import render, get_object_or_404 from django.http import HttpResponse diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000..8cc591476e --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index df916cf918..016119fbe7 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,25 @@ -# InvenTree -Open Source Inventory Management System +[![Build Status](https://travis-ci.org/inventree/InvenTree.svg?branch=master)](https://travis-ci.org/inventree/InvenTree) + +# InvenTree +InvenTree is an open-source Inventory Management System which provides powerful low-level stock control and part tracking. The core of the InvenTree system is a Python/Django database backend which provides an admin interface (web-based) and a JSON API for interaction with external interfaces and applications. + +## Installation +It is recommended to set up a clean Python 3.4+ virtual environment first: +`mkdir ~/.env && python3 -m venv ~/.env/InvenTree && source ~/.env/InvenTree/bin/activate` + +You can then continue running `make setup` (which will be replaced by a proper setup.py soon). This will do the following: + +1. Installs required Python dependencies (requires [pip](https://pypi.python.org/pypi/pip), should be part of your virtual environment by default) +1. Performs initial database setup +1. Updates database tables for all InvenTree components + +This command can also be used to update the installation if changes have been made to the database configuration. + +To create an initial user account, run the command `make superuser`. ## Documentation For project code documentation, refer to the online [documentation](http://inventree.readthedocs.io/en/latest/) (auto-generated) + +## Coding Style +If you'd like to contribute, install our development dependencies using `make develop`. +All Python code should conform to the [PEP 8](https://www.python.org/dev/peps/pep-0008/) style guide. Run `make style` which will compare all source (.py) files against the PEP 8 style. Tests can be run using `make test`. diff --git a/install.py b/install.py deleted file mode 100644 index 090fdd3e79..0000000000 --- a/install.py +++ /dev/null @@ -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'") diff --git a/requirements/base.txt b/requirements/base.txt new file mode 100644 index 0000000000..1a4283d80d --- /dev/null +++ b/requirements/base.txt @@ -0,0 +1,2 @@ +Django==1.11 +djangorestframework==3.6.2 diff --git a/requirements/build.txt b/requirements/build.txt new file mode 100644 index 0000000000..c400442ceb --- /dev/null +++ b/requirements/build.txt @@ -0,0 +1,2 @@ +-r base.txt +flake8==3.3.0 diff --git a/requirements/dev.txt b/requirements/dev.txt new file mode 100644 index 0000000000..3ec7263b93 --- /dev/null +++ b/requirements/dev.txt @@ -0,0 +1,4 @@ +-r build.txt +django-extensions==1.7.8 +graphviz==0.6 +ipython==5.3.0 diff --git a/roadmap.md b/roadmap.md new file mode 100644 index 0000000000..af9964f7b0 --- /dev/null +++ b/roadmap.md @@ -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 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000000..ea418b2daf --- /dev/null +++ b/setup.cfg @@ -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