Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2019-04-28 01:18:32 +10:00
commit c0e69d7a99
42 changed files with 713 additions and 140 deletions

2
.gitignore vendored
View File

@ -29,8 +29,6 @@ local_settings.py
# Sphinx files
docs/_build
docs/_static
docs/_templates
# Local media storage (only when running in development mode)
InvenTree/media

View File

@ -0,0 +1,5 @@
"""
The InvenTree module provides high-level management and functionality.
It provides a number of helper functions and generic classes which are used by InvenTree apps.
"""

View File

@ -1,3 +1,7 @@
"""
Helper forms which subclass Django forms to provide additional functionality
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
@ -6,6 +10,7 @@ from crispy_forms.helper import FormHelper
class HelperForm(forms.ModelForm):
""" Provides simple integration of crispy_forms extension. """
def __init__(self, *args, **kwargs):
super(forms.ModelForm, self).__init__(*args, **kwargs)

View File

@ -1,3 +1,7 @@
"""
Provides helper functions used throughout the InvenTree project
"""
import io
from wsgiref.util import FileWrapper
@ -5,7 +9,14 @@ from django.http import StreamingHttpResponse
def str2bool(text, test=True):
""" Test if a string 'looks' like a boolean value
""" Test if a string 'looks' like a boolean value.
Args:
text: Input text
test (default = True): Set which boolean value to look for
Returns:
True if the text looks like the selected boolean value
"""
if test:
return str(text).lower() in ['1', 'y', 'yes', 't', 'true', 'ok', ]
@ -13,21 +24,36 @@ def str2bool(text, test=True):
return str(text).lower() in ['0', 'n', 'no', 'none', 'f', 'false', ]
def WrapWithQuotes(text):
# TODO - Make this better
if not text.startswith('"'):
text = '"' + text
def WrapWithQuotes(text, quote='"'):
""" Wrap the supplied text with quotes
if not text.endswith('"'):
text = text + '"'
Args:
text: Input text to wrap
quote: Quote character to use for wrapping (default = "")
Returns:
Supplied text wrapped in quote char
"""
if not text.startswith(quote):
text = quote + text
if not text.endswith(quote):
text = text + quote
return text
def DownloadFile(data, filename, content_type='application/text'):
"""
Create a dynamic file for the user to download.
@param data is the raw file data
""" Create a dynamic file for the user to download.
Args:
data: Raw file data (string or bytes)
filename: Filename for the file download
content_type: Content type for the download
Return:
A StreamingHttpResponse object wrapping the supplied data
"""
filename = WrapWithQuotes(filename)

View File

@ -1,9 +1,12 @@
"""
Generic models which provide extra functionality over base Django model types.
"""
from __future__ import unicode_literals
from django.db import models
from django.contrib.contenttypes.models import ContentType
from rest_framework.exceptions import ValidationError
from .helpers import str2bool
from django.db.models.signals import pre_delete
from django.dispatch import receiver
@ -11,6 +14,7 @@ from django.dispatch import receiver
class InvenTreeTree(models.Model):
""" Provides an abstracted self-referencing tree model for data categories.
- 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)
"""
@ -69,10 +73,12 @@ class InvenTreeTree(models.Model):
@property
def has_children(self):
""" True if there are any children under this item """
return self.children.count() > 0
@property
def children(self):
""" Return the children of this item """
contents = ContentType.objects.get_for_model(type(self))
childs = contents.get_all_objects_for_this_type(parent=self.id)
@ -100,11 +106,10 @@ class InvenTreeTree(models.Model):
@property
def parentpath(self):
""" Return the parent path of this category
""" Get 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
Returns:
List of category names from the top level to the parent of this category
"""
if self.parent:
@ -114,10 +119,21 @@ class InvenTreeTree(models.Model):
@property
def path(self):
""" Get the complete part of this category.
e.g. ["Top", "Second", "Third", "This"]
Returns:
List of category names from the top level to this category
"""
return self.parentpath + [self]
@property
def pathstring(self):
""" Get a string representation for the path of this item.
e.g. "Top/Second/Third/This"
"""
return '/'.join([item.name for item in self.path])
def __setattr__(self, attrname, val):
@ -157,38 +173,19 @@ class InvenTreeTree(models.Model):
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.
"""
""" String representation of a category is the full path to that category """
return self.pathstring
@receiver(pre_delete, sender=InvenTreeTree, dispatch_uid='tree_pre_delete_log')
def before_delete_tree_item(sender, instance, using, **kwargs):
""" Receives pre_delete signal from InvenTreeTree object.
Before an item is deleted, update each child object to point to the parent of the object being deleted.
"""
# Update each tree item below this one
for child in instance.children.all():
child.parent = instance.parent
child.save()
def FilterChildren(queryset, parent):
""" Filter a queryset, limit to only objects that are a child of the given parent
Filter is passed in the URL string, e.g. '/?parent=123'
To accommodate for items without a parent, top-level items can be specified as:
none / false / null / top / 0
"""
if not parent:
return queryset
elif str2bool(parent, False):
return queryset.filter(parent=None)
else:
parent_id = int(parent)
if parent_id == 0:
return queryset.filter(parent=None)
else:
return queryset.filter(parent=parent_id)

View File

@ -1,3 +1,8 @@
"""
Serializers used in various InvenTree apps
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
@ -7,6 +12,7 @@ from django.contrib.auth.models import User
class UserSerializer(serializers.ModelSerializer):
""" Serializer for User - provides all fields """
class Meta:
model = User
@ -14,6 +20,7 @@ class UserSerializer(serializers.ModelSerializer):
class UserSerializerBrief(serializers.ModelSerializer):
""" Serializer for User - provides limited information """
class Meta:
model = User

View File

@ -1,3 +1,10 @@
"""
Top-level URL lookup for InvenTree application.
Passes URL lookup downstream to each app as required.
"""
from django.conf.urls import url, include
from django.contrib import admin
from django.contrib.auth import views as auth_views

View File

@ -1,3 +1,10 @@
"""
Various Views which provide extra functionality over base Django Views.
In particular these views provide base functionality for rendering Django forms
as JSON objects and passing them to modal forms (using jQuery / bootstrap).
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
@ -12,6 +19,8 @@ from rest_framework import views
class TreeSerializer(views.APIView):
""" JSON View for serializing a Tree object.
"""
def itemToJson(self, item):
@ -52,20 +61,34 @@ class TreeSerializer(views.APIView):
class AjaxMixin(object):
""" AjaxMixin provides basic functionality for rendering a Django form to JSON.
Handles jsonResponse rendering, and adds extra data for the modal forms to process
on the client side.
"""
ajax_form_action = ''
ajax_form_title = ''
def get_data(self):
""" Get extra context data (default implementation is empty dict)
Returns:
dict object (empty)
"""
return {}
def getAjaxTemplate(self):
if hasattr(self, 'ajax_template_name'):
return self.ajax_template_name
else:
return self.template_name
def renderJsonResponse(self, request, form=None, data={}, context={}):
""" Render a JSON response based on specific class context.
Args:
request: HTTP request object (e.g. GET / POST)
form: Django form object (may be None)
data: Extra JSON data to pass to client
context: Extra context data to pass to template rendering
Returns:
JSON response object
"""
if form:
context['form'] = form
@ -73,7 +96,7 @@ class AjaxMixin(object):
data['title'] = self.ajax_form_title
data['html_form'] = render_to_string(
self.getAjaxTemplate(),
self.ajax_template_name,
context,
request=request
)
@ -88,7 +111,8 @@ class AjaxMixin(object):
class AjaxView(AjaxMixin, View):
""" Bare-bones AjaxView """
""" An 'AJAXified' View for displaying an object
"""
# By default, point to the modal_form template
# (this can be overridden by a child class)
@ -201,7 +225,7 @@ class AjaxDeleteView(AjaxMixin, DeleteView):
data = {'id': self.get_object().id,
'delete': False,
'title': self.ajax_form_title,
'html_data': render_to_string(self.getAjaxTemplate(),
'html_data': render_to_string(self.ajax_template_name,
self.get_context_data(),
request=request)
}
@ -229,15 +253,24 @@ class AjaxDeleteView(AjaxMixin, DeleteView):
class IndexView(TemplateView):
""" View for InvenTree index page """
template_name = 'InvenTree/index.html'
class SearchView(TemplateView):
""" View for InvenTree search page.
Displays results of search query
"""
template_name = 'InvenTree/search.html'
def post(self, request, *args, **kwargs):
""" Handle POST request (which contains search query).
Pass the search query to the page template
"""
context = self.get_context_data()

View File

@ -0,0 +1,5 @@
"""
The Build module is responsible for managing "Build" transactions.
A Build consumes parts from stock to create new parts
"""

View File

@ -1,3 +1,7 @@
"""
JSON API for the Build app
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
@ -11,7 +15,12 @@ from .models import Build
from .serializers import BuildSerializer
class BuildList(generics.ListAPIView):
class BuildList(generics.ListCreateAPIView):
""" API endpoint for accessing a list of Build objects.
- GET: Return list of objects (with filters)
- POST: Create a new Build object
"""
queryset = Build.objects.all()
serializer_class = BuildSerializer

View File

@ -1,3 +1,7 @@
"""
Django Forms for interacting with Build objects
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
@ -7,6 +11,8 @@ from .models import Build
class EditBuildForm(HelperForm):
""" Form for editing a Build object.
"""
class Meta:
model = Build

View File

@ -1,3 +1,7 @@
"""
Build database model definitions
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
@ -9,60 +13,60 @@ from django.core.validators import MinValueValidator
class Build(models.Model):
""" A Build object organises the creation of new parts from the component parts
It uses the part BOM to generate new parts.
Parts are then taken from stock
""" A Build object organises the creation of new parts from the component parts.
"""
def get_absolute_url(self):
return reverse('build-detail', kwargs={'pk': self.id})
part = models.ForeignKey('part.Part', on_delete=models.CASCADE,
related_name='builds',
limit_choices_to={'buildable': True},
)
""" A reference to the part being built - only parts marked as 'buildable' may be selected """
#: Brief title describing the build
title = models.CharField(max_length=100, help_text='Brief description of the build')
#: Number of output parts to build
quantity = models.PositiveIntegerField(default=1,
validators=[MinValueValidator(1)],
help_text='Number of parts to build')
# Build status codes
PENDING = 10 # Build is pending / active
HOLDING = 20 # Build is currently being held
CANCELLED = 30 # Build was cancelled
COMPLETE = 40 # Build is complete
#: Build status codes
BUILD_STATUS_CODES = {PENDING: _("Pending"),
HOLDING: _("Holding"),
CANCELLED: _("Cancelled"),
COMPLETE: _("Complete"),
}
batch = models.CharField(max_length=100, blank=True, null=True,
help_text='Batch code for this build output')
# Status of the build
#: Status of the build (ref BUILD_STATUS_CODES)
status = models.PositiveIntegerField(default=PENDING,
choices=BUILD_STATUS_CODES.items(),
validators=[MinValueValidator(0)])
# Date the build model was 'created'
#: Batch number for the build (optional)
batch = models.CharField(max_length=100, blank=True, null=True,
help_text='Batch code for this build output')
#: Date the build model was 'created'
creation_date = models.DateField(auto_now=True, editable=False)
# Date the build was 'completed'
#: Date the build was 'completed' (and parts removed from stock)
completion_date = models.DateField(null=True, blank=True)
# Brief build title
title = models.CharField(max_length=100, help_text='Brief description of the build')
# A reference to the part being built
# Only 'buildable' parts can be selected
part = models.ForeignKey('part.Part', on_delete=models.CASCADE,
related_name='builds',
limit_choices_to={'buildable': True},
)
# How many parts to build?
quantity = models.PositiveIntegerField(default=1,
validators=[MinValueValidator(1)],
help_text='Number of parts to build')
# Notes can be attached to each build output
#: Notes attached to each build output
notes = models.TextField(blank=True)
@property
def required_parts(self):
""" Returns a dict of parts required to build this part (BOM) """
parts = []
for item in self.part.bom_items.all():
@ -77,8 +81,7 @@ class Build(models.Model):
@property
def can_build(self):
""" Return true if there are enough parts to supply build
"""
""" Return true if there are enough parts to supply build """
for item in self.required_parts:
if item['part'].total_stock < item['quantity']:
@ -88,10 +91,10 @@ class Build(models.Model):
@property
def is_active(self):
""" Is this build active?
An active build is either:
- Pending
- Holding
""" Is this build active? An active build is either:
- PENDING
- HOLDING
"""
return self.status in [
@ -101,4 +104,5 @@ class Build(models.Model):
@property
def is_complete(self):
""" Returns True if the build status is COMPLETE """
return self.status == self.COMPLETE

View File

@ -1,3 +1,7 @@
"""
JSON serializers for Build API
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
@ -7,6 +11,7 @@ from .models import Build
class BuildSerializer(serializers.ModelSerializer):
""" Serializes a Build object """
url = serializers.CharField(source='get_absolute_url', read_only=True)
status_text = serializers.CharField(source='get_status_display', read_only=True)

View File

@ -1,3 +1,7 @@
"""
URL lookup for Build app
"""
from django.conf.urls import url, include
from . import views

View File

@ -1,3 +1,7 @@
"""
Django views for interacting with Build objects
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
@ -13,11 +17,14 @@ from InvenTree.views import AjaxView, AjaxUpdateView, AjaxCreateView
class BuildIndex(ListView):
""" View for displaying list of Builds
"""
model = Build
template_name = 'build/index.html'
context_object_name = 'builds'
def get_queryset(self):
""" Return all Build objects (order by date, newest first) """
return Build.objects.order_by('status', '-completion_date')
def get_context_data(self, **kwargs):
@ -35,6 +42,9 @@ class BuildIndex(ListView):
class BuildCancel(AjaxView):
""" View to cancel a Build.
Provides a cancellation information dialog
"""
model = Build
template_name = 'build/cancel.html'
ajax_form_title = 'Cancel Build'
@ -42,6 +52,7 @@ class BuildCancel(AjaxView):
fields = []
def post(self, request, *args, **kwargs):
""" Handle POST request. Mark the build status as CANCELLED """
build = get_object_or_404(Build, pk=self.kwargs['pk'])
@ -51,24 +62,28 @@ class BuildCancel(AjaxView):
return self.renderJsonResponse(request, None)
def get_data(self):
""" Provide JSON context data. """
return {
'info': 'Build was cancelled'
}
class BuildDetail(DetailView):
""" Detail view of a single Build object. """
model = Build
template_name = 'build/detail.html'
context_object_name = 'build'
class BuildAllocate(DetailView):
""" View for allocating parts to a Build """
model = Build
context_object_name = 'build'
template_name = 'build/allocate.html'
class BuildCreate(AjaxCreateView):
""" View to create a new Build object """
model = Build
context_object_name = 'build'
form_class = EditBuildForm
@ -76,6 +91,11 @@ class BuildCreate(AjaxCreateView):
ajax_template_name = 'modal_form.html'
def get_initial(self):
""" Get initial parameters for Build creation.
If 'part' is specified in the GET query, initialize the Build with the specified Part
"""
initials = super(BuildCreate, self).get_initial().copy()
part_id = self.request.GET.get('part', None)
@ -92,6 +112,8 @@ class BuildCreate(AjaxCreateView):
class BuildUpdate(AjaxUpdateView):
""" View for editing a Build object """
model = Build
form_class = EditBuildForm
context_object_name = 'build'

View File

@ -0,0 +1,8 @@
"""
The Company module is responsible for managing Company interactions.
A company can be either (or both):
- Supplier
- Customer
"""

View File

@ -1,3 +1,7 @@
"""
Provides a JSON API for the Company app
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
@ -12,6 +16,13 @@ from .serializers import CompanySerializer
class CompanyList(generics.ListCreateAPIView):
""" API endpoint for accessing a list of Company objects
Provides two methods:
- GET: Return list of objects
- POST: Create a new Company object
"""
serializer_class = CompanySerializer
queryset = Company.objects.all()
@ -44,6 +55,7 @@ class CompanyList(generics.ListCreateAPIView):
class CompanyDetail(generics.RetrieveUpdateDestroyAPIView):
""" API endpoint for detail of a single Company object """
queryset = Company.objects.all()
serializer_class = CompanySerializer

View File

@ -1,3 +1,7 @@
"""
Django Forms for interacting with Company app
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
@ -7,6 +11,7 @@ from .models import Company
class EditCompanyForm(HelperForm):
""" Form for editing a Company object """
class Meta:
model = Company
@ -26,6 +31,7 @@ class EditCompanyForm(HelperForm):
class CompanyImageForm(HelperForm):
""" Form for uploading a Company image """
class Meta:
model = Company

View File

@ -1,3 +1,7 @@
"""
Company database model definitions
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
@ -8,6 +12,16 @@ from django.urls import reverse
def rename_company_image(instance, filename):
""" Function to rename a company image after upload
Args:
instance: Company object
filename: uploaded image filename
Returns:
New image filename
"""
base = 'company_images'
if filename.count('.') > 0:
@ -24,6 +38,9 @@ def rename_company_image(instance, filename):
class Company(models.Model):
""" A Company object represents an external company.
It may be a supplier or a customer (or both).
"""
name = models.CharField(max_length=100, unique=True,
help_text='Company name')
@ -54,21 +71,28 @@ class Company(models.Model):
is_supplier = models.BooleanField(default=True)
def __str__(self):
""" Get string representation of a Company """
return "{n} - {d}".format(n=self.name, d=self.description)
def get_absolute_url(self):
""" Get the web URL for the detail view for this Company """
return reverse('company-detail', kwargs={'pk': self.id})
@property
def part_count(self):
""" The number of parts supplied by this company """
return self.parts.count()
@property
def has_parts(self):
""" Return True if this company supplies any parts """
return self.part_count > 0
class Contact(models.Model):
""" A Contact represents a person who works at a particular company.
A Company may have zero or more associated Contact objects
"""
name = models.CharField(max_length=100)

View File

@ -1,9 +1,14 @@
"""
JSON serializers for Company app
"""
from rest_framework import serializers
from .models import Company
class CompanyBriefSerializer(serializers.ModelSerializer):
""" Serializer for Company object (limited detail) """
url = serializers.CharField(source='get_absolute_url', read_only=True)
@ -17,6 +22,7 @@ class CompanyBriefSerializer(serializers.ModelSerializer):
class CompanySerializer(serializers.ModelSerializer):
""" Serializer for Company object (full detail) """
url = serializers.CharField(source='get_absolute_url', read_only=True)

View File

@ -1,3 +1,8 @@
"""
URL lookup for Company app
"""
from django.conf.urls import url, include
from django.views.generic.base import RedirectView

View File

@ -1,3 +1,8 @@
"""
Django views for interacting with Company app
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
@ -12,12 +17,20 @@ from .forms import CompanyImageForm
class CompanyIndex(ListView):
""" View for displaying list of companies
"""
model = Company
template_name = 'company/index.html'
context_object_name = 'companies'
paginate_by = 50
def get_queryset(self):
""" Retrieve the Company queryset based on HTTP request parameters.
- supplier: Filter by supplier
- customer: Filter by customer
"""
queryset = Company.objects.all().order_by('name')
if self.request.GET.get('supplier', None):
@ -30,6 +43,7 @@ class CompanyIndex(ListView):
class CompanyDetail(DetailView):
""" Detail view for Company object """
context_obect_name = 'company'
template_name = 'company/detail.html'
queryset = Company.objects.all()
@ -37,6 +51,7 @@ class CompanyDetail(DetailView):
class CompanyImage(AjaxUpdateView):
""" View for uploading an image for the Company """
model = Company
ajax_template_name = 'modal_form.html'
ajax_form_title = 'Update Company Image'
@ -49,6 +64,7 @@ class CompanyImage(AjaxUpdateView):
class CompanyEdit(AjaxUpdateView):
""" View for editing a Company object """
model = Company
form_class = EditCompanyForm
context_object_name = 'company'
@ -62,6 +78,7 @@ class CompanyEdit(AjaxUpdateView):
class CompanyCreate(AjaxCreateView):
""" View for creating a new Company object """
model = Company
context_object_name = 'company'
form_class = EditCompanyForm
@ -75,6 +92,7 @@ class CompanyCreate(AjaxCreateView):
class CompanyDelete(AjaxDeleteView):
""" View for deleting a Company object """
model = Company
success_url = '/company/'
ajax_template_name = 'company/delete.html'

View File

@ -1,7 +1,5 @@
"""
Module keygen
=============
This module generates a Django SECRET_KEY file to be used by manage.py
Generates a Django SECRET_KEY file to be used by manage.py
"""
import random
@ -15,11 +13,13 @@ KEY_DIR = os.path.dirname(os.path.realpath(__file__))
def generate_key(length=50):
"""
Generate a random string
""" Generate a random string
:param length: Number of characters in returned string (default=50)
:returns: Randomized secret key string
Args:
length: Number of characters in returned string (default = 50)
Returns:
Randomized secret key string
"""
options = string.digits + string.ascii_letters + string.punctuation

View File

@ -0,0 +1,10 @@
"""
The Part module is responsible for Part management.
It includes models for:
- PartCategory
- Part
- SupplierPart
- BomItem
"""

View File

@ -1,3 +1,7 @@
"""
Provides a JSON API for the Part app
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
@ -26,6 +30,12 @@ class PartCategoryTree(TreeSerializer):
class CategoryList(generics.ListCreateAPIView):
""" API endpoint for accessing a list of PartCategory objects.
- GET: Return a list of PartCategory objects
- POST: Create a new PartCategory object
"""
queryset = PartCategory.objects.all()
serializer_class = CategorySerializer
@ -56,11 +66,13 @@ class CategoryList(generics.ListCreateAPIView):
class CategoryDetail(generics.RetrieveUpdateDestroyAPIView):
""" API endpoint for detail view of a single PartCategory object """
serializer_class = CategorySerializer
queryset = PartCategory.objects.all()
class PartDetail(generics.RetrieveUpdateDestroyAPIView):
""" API endpoint for detail view of a single Part object """
queryset = Part.objects.all()
serializer_class = PartSerializer
@ -70,6 +82,11 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView):
class PartList(generics.ListCreateAPIView):
""" API endpoint for accessing a list of Part objects
- GET: Return list of objects
- POST: Create a new Part object
"""
serializer_class = PartSerializer
@ -130,6 +147,11 @@ class PartList(generics.ListCreateAPIView):
class BomList(generics.ListCreateAPIView):
""" API endpoing for accessing a list of BomItem objects
- GET: Return list of BomItem objects
- POST: Create a new BomItem object
"""
queryset = BomItem.objects.all()
serializer_class = BomItemSerializer
@ -151,6 +173,7 @@ class BomList(generics.ListCreateAPIView):
class BomDetail(generics.RetrieveUpdateDestroyAPIView):
""" API endpoint for detail view of a single BomItem object """
queryset = BomItem.objects.all()
serializer_class = BomItemSerializer
@ -161,6 +184,11 @@ class BomDetail(generics.RetrieveUpdateDestroyAPIView):
class SupplierPartList(generics.ListCreateAPIView):
""" API endpoint for list view of SupplierPart object
- GET: Return list of SupplierPart objects
- POST: Create a new SupplierPart object
"""
queryset = SupplierPart.objects.all()
serializer_class = SupplierPartSerializer
@ -182,6 +210,12 @@ class SupplierPartList(generics.ListCreateAPIView):
class SupplierPartDetail(generics.RetrieveUpdateDestroyAPIView):
""" API endpoint for detail view of SupplierPart object
- GET: Retrieve detail view
- PATCH: Update object
- DELETE: Delete objec
"""
queryset = SupplierPart.objects.all()
serializer_class = SupplierPartSerializer
@ -192,6 +226,11 @@ class SupplierPartDetail(generics.RetrieveUpdateDestroyAPIView):
class SupplierPriceBreakList(generics.ListCreateAPIView):
""" API endpoint for list view of SupplierPriceBreak object
- GET: Retrieve list of SupplierPriceBreak objects
- POST: Create a new SupplierPriceBreak object
"""
queryset = SupplierPriceBreak.objects.all()
serializer_class = SupplierPriceBreakSerializer

View File

@ -1,3 +1,7 @@
"""
Django Forms for interacting with Part objects
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
@ -10,6 +14,7 @@ from .models import SupplierPart
class PartImageForm(HelperForm):
""" Form for uploading a Part image """
class Meta:
model = Part
@ -40,6 +45,7 @@ class BomExportForm(HelperForm):
class EditPartForm(HelperForm):
""" Form for editing a Part object """
class Meta:
model = Part
@ -64,6 +70,7 @@ class EditPartForm(HelperForm):
class EditCategoryForm(HelperForm):
""" Form for editing a PartCategory object """
class Meta:
model = PartCategory
@ -75,6 +82,7 @@ class EditCategoryForm(HelperForm):
class EditBomItemForm(HelperForm):
""" Form for editing a BomItem object """
class Meta:
model = BomItem
@ -88,6 +96,7 @@ class EditBomItemForm(HelperForm):
class EditSupplierPartForm(HelperForm):
""" Form for editing a SupplierPart object """
class Meta:
model = SupplierPart

View File

@ -1,3 +1,7 @@
"""
Part database model definitions
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
@ -47,11 +51,19 @@ class PartCategory(InvenTreeTree):
@property
def has_parts(self):
""" True if there are any parts in this category """
return self.parts.count() > 0
@receiver(pre_delete, sender=PartCategory, dispatch_uid='partcategory_delete_log')
def before_delete_part_category(sender, instance, using, **kwargs):
""" Receives before_delete signal for PartCategory object
Before deleting, update child Part and PartCategory objects:
- For each child category, set the parent to the parent of *this* category
- For each part, set the 'category' to the parent of *this* category
"""
# Update each part in this category to point to the parent category
for part in instance.parts.all():
@ -67,6 +79,16 @@ def before_delete_part_category(sender, instance, using, **kwargs):
# Function to automatically rename a part image on upload
# Format: part_pk.<img>
def rename_part_image(instance, filename):
""" Function for renaming a part image file
Args:
instance: Instance of a Part object
filename: Name of original uploaded file
Returns:
Cleaned filename in format part_<n>_img
"""
base = 'part_images'
if filename.count('.') > 0:
@ -248,7 +270,8 @@ class Part(models.Model):
@property
def allocation_count(self):
""" Return true if any of this part is allocated
""" Return true if any of this part is allocated:
- To another build
- To a customer order
"""
@ -311,6 +334,15 @@ class Part(models.Model):
def attach_file(instance, filename):
""" Function for storing a file for a PartAttachment
Args:
instance: Instance of a PartAttachment object
filename: name of uploaded file
Returns:
path to store file, format: 'part_file_<pk>_filename'
"""
# Construct a path to store a file attachment
return os.path.join('part_files', str(instance.part.id), filename)
@ -356,6 +388,13 @@ class BomItem(models.Model):
note = models.CharField(max_length=100, blank=True, help_text='Item notes')
def clean(self):
""" Check validity of the BomItem model.
Performs model checks beyond simple field validation.
- A part cannot refer to itself in its BOM
- A part cannot refer to a part which refers to it
"""
# A part cannot refer to itself in its BOM
if self.part == self.sub_part:
@ -382,8 +421,9 @@ class BomItem(models.Model):
class SupplierPart(models.Model):
""" Represents a unique part as provided by a Supplier
Each SupplierPart is identified by a MPN (Manufacturer Part Number)
Each SupplierPart is also linked to a Part object
- A Part may be available from multiple suppliers
Each SupplierPart is also linked to a Part object.
A Part may be available from multiple suppliers
"""
def get_absolute_url(self):
@ -453,6 +493,7 @@ class SupplierPart(models.Model):
def get_price(self, quantity, moq=True, multiples=True):
""" Calculate the supplier price based on quantity price breaks.
- If no price breaks available, use the single_price field
- Don't forget to add in flat-fee cost (base_cost field)
- If MOQ (minimum order quantity) is required, bump quantity

View File

@ -1,3 +1,7 @@
"""
JSON serializers for Part app
"""
from rest_framework import serializers
from .models import Part, PartCategory, BomItem
@ -7,6 +11,7 @@ from InvenTree.serializers import InvenTreeModelSerializer
class CategorySerializer(serializers.ModelSerializer):
""" Serializer for PartCategory """
url = serializers.CharField(source='get_absolute_url', read_only=True)
@ -23,6 +28,7 @@ class CategorySerializer(serializers.ModelSerializer):
class PartBriefSerializer(serializers.ModelSerializer):
""" Serializer for Part (brief detail) """
url = serializers.CharField(source='get_absolute_url', read_only=True)
@ -68,6 +74,7 @@ class PartSerializer(serializers.ModelSerializer):
class BomItemSerializer(InvenTreeModelSerializer):
""" Serializer for BomItem object """
# url = serializers.CharField(source='get_absolute_url', read_only=True)
@ -89,6 +96,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
class SupplierPartSerializer(serializers.ModelSerializer):
""" Serializer for SupplierPart object """
url = serializers.CharField(source='get_absolute_url', read_only=True)
@ -112,6 +120,7 @@ class SupplierPartSerializer(serializers.ModelSerializer):
class SupplierPriceBreakSerializer(serializers.ModelSerializer):
""" Serializer for SupplierPriceBreak object """
class Meta:
model = SupplierPriceBreak

View File

@ -1,3 +1,7 @@
"""
URL lookup for Part app
"""
from django.conf.urls import url, include
from . import views

View File

@ -1,3 +1,7 @@
"""
Django views for interacting with Part app
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
@ -21,10 +25,12 @@ from .forms import EditSupplierPartForm
from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
from InvenTree.helpers import DownloadFile
from InvenTree.helpers import DownloadFile, str2bool
class PartIndex(ListView):
""" View for displaying list of Part objects
"""
model = Part
template_name = 'part/category.html'
context_object_name = 'parts'
@ -45,8 +51,12 @@ class PartIndex(ListView):
class PartCreate(AjaxCreateView):
""" Create a new part
- Optionally provide a category object as initial data
""" View for creating a new Part object.
Options for providing initial conditions:
- Provide a category object as initial data
- Copy an existing Part
"""
model = Part
form_class = EditPartForm
@ -64,6 +74,10 @@ class PartCreate(AjaxCreateView):
# If a category is provided in the URL, pass that to the page context
def get_context_data(self, **kwargs):
""" Provide extra context information for the form to display:
- Add category information (if provided)
"""
context = super(PartCreate, self).get_context_data(**kwargs)
# Add category information to the page
@ -76,6 +90,11 @@ class PartCreate(AjaxCreateView):
# Pre-fill the category field if a valid category is provided
def get_initial(self):
""" Get initial data for the new Part object:
- If a category is provided, pre-fill the Category field
- If 'copy' parameter is provided, copy from referenced Part
"""
# Is the client attempting to copy an existing part?
part_to_copy = self.request.GET.get('copy', None)
@ -98,15 +117,22 @@ class PartCreate(AjaxCreateView):
class PartDetail(DetailView):
""" Detail view for Part object
"""
context_object_name = 'part'
queryset = Part.objects.all()
template_name = 'part/detail.html'
# Add in some extra context information based on query params
def get_context_data(self, **kwargs):
""" Provide extra context data to template
- If '?editing=True', set 'editing_enabled' context variable
"""
context = super(PartDetail, self).get_context_data(**kwargs)
if self.request.GET.get('edit', '').lower() in ['true', 'yes', '1']:
if str2bool(self.request.GET.get('edit', '')):
context['editing_enabled'] = 1
else:
context['editing_enabled'] = 0
@ -115,6 +141,7 @@ class PartDetail(DetailView):
class PartImage(AjaxUpdateView):
""" View for uploading Part image """
model = Part
ajax_template_name = 'modal_form.html'
@ -128,6 +155,8 @@ class PartImage(AjaxUpdateView):
class PartEdit(AjaxUpdateView):
""" View for editing Part object """
model = Part
template_name = 'part/edit.html'
form_class = EditPartForm
@ -214,6 +243,8 @@ class BomDownload(AjaxView):
class PartDelete(AjaxDeleteView):
""" View to delete a Part object """
model = Part
template_name = 'part/delete.html'
ajax_template_name = 'part/partial_delete.html'
@ -229,6 +260,7 @@ class PartDelete(AjaxDeleteView):
class CategoryDetail(DetailView):
""" Detail view for PartCategory """
model = PartCategory
context_object_name = 'category'
queryset = PartCategory.objects.all()
@ -236,6 +268,7 @@ class CategoryDetail(DetailView):
class CategoryEdit(AjaxUpdateView):
""" Update view to edit a PartCategory """
model = PartCategory
template_name = 'part/category_edit.html'
form_class = EditCategoryForm
@ -251,6 +284,7 @@ class CategoryEdit(AjaxUpdateView):
class CategoryDelete(AjaxDeleteView):
""" Delete view to delete a PartCategory """
model = PartCategory
template_name = 'part/category_delete.html'
context_object_name = 'category'
@ -263,6 +297,7 @@ class CategoryDelete(AjaxDeleteView):
class CategoryCreate(AjaxCreateView):
""" Create view to make a new PartCategory """
model = PartCategory
ajax_form_action = reverse_lazy('category-create')
ajax_form_title = 'Create new part category'
@ -271,6 +306,10 @@ class CategoryCreate(AjaxCreateView):
form_class = EditCategoryForm
def get_context_data(self, **kwargs):
""" Add extra context data to template.
- If parent category provided, pass the category details to the template
"""
context = super(CategoryCreate, self).get_context_data(**kwargs).copy()
parent_id = self.request.GET.get('category', None)
@ -281,6 +320,10 @@ class CategoryCreate(AjaxCreateView):
return context
def get_initial(self):
""" Get initial data for new PartCategory
- If parent provided, pre-fill the parent category
"""
initials = super(CategoryCreate, self).get_initial().copy()
parent_id = self.request.GET.get('category', None)
@ -292,12 +335,14 @@ class CategoryCreate(AjaxCreateView):
class BomItemDetail(DetailView):
""" Detail view for BomItem """
context_object_name = 'item'
queryset = BomItem.objects.all()
template_name = 'part/bom-detail.html'
class BomItemCreate(AjaxCreateView):
""" Create view for making a new BomItem object """
model = BomItem
form_class = EditBomItemForm
template_name = 'part/bom-create.html'
@ -305,6 +350,11 @@ class BomItemCreate(AjaxCreateView):
ajax_form_title = 'Create BOM item'
def get_initial(self):
""" Provide initial data for the BomItem:
- If 'parent' provided, set the parent part field
"""
# Look for initial values
initials = super(BomItemCreate, self).get_initial().copy()
@ -318,6 +368,8 @@ class BomItemCreate(AjaxCreateView):
class BomItemEdit(AjaxUpdateView):
""" Update view for editing BomItem """
model = BomItem
form_class = EditBomItemForm
template_name = 'part/bom-edit.html'
@ -326,6 +378,7 @@ class BomItemEdit(AjaxUpdateView):
class BomItemDelete(AjaxDeleteView):
""" Delete view for removing BomItem """
model = BomItem
template_name = 'part/bom-delete.html'
context_object_name = 'item'
@ -333,6 +386,7 @@ class BomItemDelete(AjaxDeleteView):
class SupplierPartDetail(DetailView):
""" Detail view for SupplierPart """
model = SupplierPart
template_name = 'company/partdetail.html'
context_object_name = 'part'
@ -340,6 +394,8 @@ class SupplierPartDetail(DetailView):
class SupplierPartEdit(AjaxUpdateView):
""" Update view for editing SupplierPart """
model = SupplierPart
template_name = 'company/partedit.html'
context_object_name = 'part'
@ -349,6 +405,8 @@ class SupplierPartEdit(AjaxUpdateView):
class SupplierPartCreate(AjaxCreateView):
""" Create view for making new SupplierPart """
model = SupplierPart
form_class = EditSupplierPartForm
ajax_template_name = 'modal_form.html'
@ -356,6 +414,11 @@ class SupplierPartCreate(AjaxCreateView):
context_object_name = 'part'
def get_initial(self):
""" Provide initial data for new SupplierPart:
- If 'supplier_id' provided, pre-fill supplier field
- If 'part_id' provided, pre-fill part field
"""
initials = super(SupplierPartCreate, self).get_initial().copy()
supplier_id = self.request.GET.get('supplier', None)
@ -374,6 +437,7 @@ class SupplierPartCreate(AjaxCreateView):
class SupplierPartDelete(AjaxDeleteView):
""" Delete view for removing a SupplierPart """
model = SupplierPart
success_url = '/supplier/'
template_name = 'company/partdelete.html'

View File

@ -0,0 +1,9 @@
"""
The Stock module is responsible for Stock management.
It includes models for:
- StockLocation
- StockItem
- StockItemTracking
"""

View File

@ -1,3 +1,7 @@
"""
JSON API for the Stock app
"""
from django_filters.rest_framework import FilterSet, DjangoFilterBackend
from django_filters import NumberFilter
@ -26,7 +30,7 @@ class StockCategoryTree(TreeSerializer):
class StockDetail(generics.RetrieveUpdateDestroyAPIView):
"""
""" API detail endpoint for Stock object
get:
Return a single StockItem object
@ -44,6 +48,11 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
class StockFilter(FilterSet):
""" FilterSet for advanced stock filtering.
Allows greater-than / less-than filtering for stock quantity
"""
min_stock = NumberFilter(name='quantity', lookup_expr='gte')
max_stock = NumberFilter(name='quantity', lookup_expr='lte')
@ -53,12 +62,11 @@ class StockFilter(FilterSet):
class StockStocktake(APIView):
"""
Stocktake API endpoint provides stock update of multiple items simultaneously
""" Stocktake API endpoint provides stock update of multiple items simultaneously.
The 'action' field tells the type of stock action to perform:
* 'stocktake' - Count the stock item(s)
* 'remove' - Remove the quantity provided from stock
* 'add' - Add the quantity provided from stock
- stocktake: Count the stock item(s)
- remove: Remove the quantity provided from stock
- add: Add the quantity provided from stock
"""
permission_classes = [
@ -129,6 +137,7 @@ class StockStocktake(APIView):
class StockMove(APIView):
""" API endpoint for performing stock movements """
permission_classes = [
permissions.IsAuthenticatedOrReadOnly,
@ -183,6 +192,11 @@ class StockMove(APIView):
class StockLocationList(generics.ListCreateAPIView):
""" API endpoint for list view of StockLocation objects:
- GET: Return list of StockLocation objects
- POST: Create a new StockLocation
"""
queryset = StockLocation.objects.all()
@ -204,14 +218,10 @@ class StockLocationList(generics.ListCreateAPIView):
class StockList(generics.ListCreateAPIView):
"""
""" API endpoint for list view of Stock objects
get:
Return a list of all StockItem objects
(with optional query filters)
post:
Create a new StockItem
- GET: Return a list of all StockItem objects (with optional query filters)
- POST: Create a new StockItem
"""
def get_queryset(self):
@ -268,6 +278,7 @@ class StockList(generics.ListCreateAPIView):
class StockStocktakeEndpoint(generics.UpdateAPIView):
""" API endpoint for performing stocktake """
queryset = StockItem.objects.all()
serializer_class = StockQuantitySerializer
@ -283,6 +294,13 @@ class StockStocktakeEndpoint(generics.UpdateAPIView):
class StockTrackingList(generics.ListCreateAPIView):
""" API endpoint for list view of StockItemTracking objects.
StockItemTracking objects are read-only
(they are created by internal model functionality)
- GET: Return list of StockItemTracking objects
"""
queryset = StockItemTracking.objects.all()
serializer_class = StockTrackingSerializer
@ -312,17 +330,11 @@ class StockTrackingList(generics.ListCreateAPIView):
class LocationDetail(generics.RetrieveUpdateDestroyAPIView):
"""
get:
Return a single StockLocation object
post:
Update a StockLocation object
delete:
Remove a StockLocation object
""" API endpoint for detail view of StockLocation object
- GET: Return a single StockLocation object
- PATCH: Update a StockLocation object
- DELETE: Remove a StockLocation object
"""
queryset = StockLocation.objects.all()

View File

@ -1,3 +1,7 @@
"""
Django Forms for interacting with Stock app
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
@ -8,6 +12,7 @@ from .models import StockLocation, StockItem
class EditStockLocationForm(HelperForm):
""" Form for editing a StockLocation """
class Meta:
model = StockLocation
@ -19,6 +24,7 @@ class EditStockLocationForm(HelperForm):
class CreateStockItemForm(HelperForm):
""" Form for creating a new StockItem """
class Meta:
model = StockItem
@ -38,6 +44,7 @@ class CreateStockItemForm(HelperForm):
class MoveStockItemForm(forms.ModelForm):
""" Form for moving a StockItem to a new location """
note = forms.CharField(label='Notes', required=True, help_text='Add note (required)')
@ -60,6 +67,7 @@ class StocktakeForm(forms.ModelForm):
class EditStockItemForm(HelperForm):
""" Form for editing a StockItem object """
class Meta:
model = StockItem

View File

@ -1,3 +1,8 @@
"""
Stock database model definitions
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals

View File

@ -1,3 +1,7 @@
"""
JSON serializers for Stock app
"""
from rest_framework import serializers
from .models import StockItem, StockLocation
@ -44,8 +48,8 @@ class StockItemSerializerBrief(serializers.ModelSerializer):
class StockItemSerializer(serializers.ModelSerializer):
"""
Serializer for a StockItem
""" Serializer for a StockItem:
- Includes serialization for the linked part
- Includes serialization for the item location
"""
@ -112,6 +116,7 @@ class LocationSerializer(serializers.ModelSerializer):
class StockTrackingSerializer(serializers.ModelSerializer):
""" Serializer for StockItemTracking model """
url = serializers.CharField(source='get_absolute_url', read_only=True)

View File

@ -1,3 +1,7 @@
"""
URL lookup for Stock app
"""
from django.conf.urls import url, include
from . import views

View File

@ -1,3 +1,7 @@
"""
Django views for interacting with Stock app
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
@ -19,8 +23,7 @@ from .forms import StocktakeForm
class StockIndex(ListView):
"""
StockIndex view loads all StockLocation and StockItem object
""" StockIndex view loads all StockLocation and StockItem object
"""
model = StockItem
template_name = 'stock/location.html'

View File

@ -28,15 +28,22 @@ copyright = '2019, InvenTree'
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
'autoapi.extension'
'sphinx.ext.autodoc',
'sphinx.ext.napoleon',
'autoapi.extension',
]
napoleon_google_docstring = True
napoleon_numpy_docstring = False
autoapi_dirs = [
'../InvenTree',
]
autoapi_options = [
'members',
'private-members',
'special-members',
]
autoapi_type = 'python'
@ -44,11 +51,21 @@ autoapi_type = 'python'
autoapi_ignore = [
'*migrations*',
'**/test*.py',
'**/manage.py'
'**/manage.py',
'**/apps.py',
'**/admin.py',
'**/middleware.py',
'**/utils.py',
'**/wsgi.py',
'**/templates/',
]
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
autoapi_template_dir = 'templates'
autoapi_root = 'docs'
autoapi_add_toctree_entry = False
templates_path = ['templates']
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
@ -73,3 +90,11 @@ html_theme = 'sphinx_rtd_theme'
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# Table of contents in sidebar
html_sidebars = {'**': [
'globaltoc.html',
'relations.html',
'sourcelink.html',
'searchbox.html'
]}

View File

@ -1,20 +1,12 @@
.. InvenTree documentation master file, created by
sphinx-quickstart on Sat Apr 27 15:45:39 2019.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
InvenTree's Django Documentation
================================
Welcome to InvenTree's documentation!
=====================================
This documentation is auto-generated from the `InvenTree codebase <https://github.com/InvenTree/InvenTree>`_
.. toctree::
:titlesonly:
:maxdepth: 2
:caption: Contents:
:hidden:
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`
InvenTree <introduction>

18
docs/introduction.rst Normal file
View File

@ -0,0 +1,18 @@
InvenTree
=========
InvenTree is an open source inventory management system which provides powerful low-level part management and stock tracking functionality.
The core of the InvenTree software is a Python/Django database backend whi
Django Apps
===========
The InvenTree Django ecosystem provides the following 'apps' for core functionality:
* `InvenTree </docs/InvenTree/index.html>`_ - High level management functions
* `Build </docs/build/index.html>`_ - Part build projects
* `Company </docs/company/index.html>`_ - Company management (suppliers / customers)
* `Part </docs/part/index.html>`_ - Part management
* `Stock </docs/stock/index.html>`_ - Stock management

7
docs/templates/layout.html vendored Normal file
View File

@ -0,0 +1,7 @@
{% extends "!layout.html" %}
{% block menu %}
{{ super() }}
<a href="/py-modindex.html">Module Index</a>
<a href="/genindex.html">Index</a>
{% endblock %}

97
docs/templates/python/module.rst vendored Normal file
View File

@ -0,0 +1,97 @@
{% if not obj.display %}
:orphan:
{% endif %}
:mod:`{{ obj.name }}`
======={{ "=" * obj.name|length }}
.. py:module:: {{ obj.name }}
{% if obj.docstring %}
.. autoapi-nested-parse::
{{ obj.docstring|prepare_docstring|indent(3) }}
{% endif %}
{% block subpackages %}
{% set visible_subpackages = obj.subpackages|selectattr("display")|list %}
{% if visible_subpackages %}
Subpackages
-----------
.. toctree::
:titlesonly:
:maxdepth: 3
{% for subpackage in visible_subpackages %}
{{ subpackage.short_name }}/index.rst
{% endfor %}
{% endif %}
{% endblock %}
{% block submodules %}
{% set visible_submodules = obj.submodules|selectattr("display")|list %}
{% if visible_submodules %}
Submodules
----------
The {{ obj.name }} module contains the following submodules
.. toctree::
:titlesonly:
:maxdepth: 1
{% for submodule in visible_submodules %}
{{ submodule.short_name }}/index.rst
{% endfor %}
{% endif %}
{% endblock %}
{% block content %}
{% set visible_children = obj.children|selectattr("display")|list %}
{% if visible_children %}
{{ obj.type|title }} Contents
{{ "-" * obj.type|length }}---------
{% set visible_classes = visible_children|selectattr("type", "equalto", "class")|list %}
{% set visible_functions = visible_children|selectattr("type", "equalto", "function")|list %}
{% if include_summaries and (visible_classes or visible_functions) %}
{% block classes %}
{% if visible_classes %}
Classes
~~~~~~~
.. autoapisummary::
{% for klass in visible_classes %}
{{ klass.id }}
{% endfor %}
{% endif %}
{% endblock %}
{% block functions %}
{% if visible_functions %}
Functions
~~~~~~~~~
.. autoapisummary::
{% for function in visible_functions %}
{{ function.id }}
{% endfor %}
{% endif %}
{% endblock %}
{% endif %}
{% for obj_item in visible_children %}
{% if obj.all is none or obj_item.short_name in obj.all %}
{{ obj_item.rendered|indent(0) }}
{% endif %}
{% endfor %}
{% endif %}
{% endblock %}