Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2019-04-28 11:29:23 +10:00
commit ec166c906f
16 changed files with 325 additions and 154 deletions

View File

@ -9,7 +9,8 @@ addons:
-sqlite3
before_install:
- make setup
- make install
- make migrate
script:
- make coverage

View File

@ -69,6 +69,22 @@ class AjaxMixin(object):
ajax_form_action = ''
ajax_form_title = ''
def get_param(self, name, method='GET'):
""" Get a request query parameter value from URL e.g. ?part=3
Args:
name: Variable name e.g. 'part'
method: Request type ('GET' or 'POST')
Returns:
Value of the supplier parameter or None if parameter is not available
"""
if method == 'POST':
return self.request.POST.get(name, None)
else:
return self.request.GET.get(name, None)
def get_data(self):
""" Get extra context data (default implementation is empty dict)
@ -134,79 +150,82 @@ class AjaxCreateView(AjaxMixin, CreateView):
"""
def get(self, request, *args, **kwargs):
""" Creates form with initial data, and renders JSON response """
response = super(CreateView, self).get(request, *args, **kwargs)
super(CreateView, self).get(request, *args, **kwargs)
if request.is_ajax():
# Initialize a a new form
form = self.form_class(initial=self.get_initial())
return self.renderJsonResponse(request, form)
else:
return response
form = self.get_form()
return self.renderJsonResponse(request, form)
def post(self, request, *args, **kwargs):
form = self.form_class(data=request.POST, files=request.FILES)
""" Responds to form POST. Validates POST data and returns status info.
if request.is_ajax():
- Validate POST form data
- If valid, save form
- Return status info (success / failure)
"""
form = self.get_form()
data = {
'form_valid': form.is_valid(),
}
# Extra JSON data sent alongside form
data = {
'form_valid': form.is_valid(),
}
if form.is_valid():
obj = form.save()
if form.is_valid():
obj = form.save()
# Return the PK of the newly-created object
data['pk'] = obj.pk
# Return the PK of the newly-created object
data['pk'] = obj.pk
data['url'] = obj.get_absolute_url()
data['url'] = obj.get_absolute_url()
return self.renderJsonResponse(request, form, data)
else:
return super(CreateView, self).post(request, *args, **kwargs)
return self.renderJsonResponse(request, form, data)
class AjaxUpdateView(AjaxMixin, UpdateView):
""" An 'AJAXified' UpdateView for updating an object in the db
- Returns form in JSON format (for delivery to a modal window)
- Handles repeated form validation (via AJAX) until the form is valid
"""
def get(self, request, *args, **kwargs):
""" Respond to GET request.
html_response = super(UpdateView, self).get(request, *args, **kwargs)
- Populates form with object data
- Renders form to JSON and returns to client
"""
if request.is_ajax():
form = self.form_class(instance=self.get_object())
super(UpdateView, self).get(request, *args, **kwargs)
return self.renderJsonResponse(request, form)
else:
return html_response
form = self.get_form()
return self.renderJsonResponse(request, form)
def post(self, request, *args, **kwargs):
""" Respond to POST request.
form = self.form_class(instance=self.get_object(), data=request.POST, files=request.FILES)
- Updates model with POST field data
- Performs form and object validation
- If errors exist, re-render the form
- Otherwise, return sucess status
"""
if request.is_ajax():
super(UpdateView, self).post(request, *args, **kwargs)
data = {'form_valid': form.is_valid()}
form = self.get_form()
if form.is_valid():
obj = form.save()
data = {
'form_valid': form.is_valid()
}
data['pk'] = obj.id
data['url'] = obj.get_absolute_url()
if form.is_valid():
obj = form.save()
# Include context data about the updated object
data['pk'] = obj.id
data['url'] = obj.get_absolute_url()
response = self.renderJsonResponse(request, form, data)
return response
else:
return super(UpdateView, self).post(request, *args, **kwargs)
return self.renderJsonResponse(request, form, data)
class AjaxDeleteView(AjaxMixin, DeleteView):
@ -217,39 +236,43 @@ class AjaxDeleteView(AjaxMixin, DeleteView):
"""
def get(self, request, *args, **kwargs):
""" Respond to GET request
html_response = super(DeleteView, self).get(request, *args, **kwargs)
- Render a DELETE confirmation form to JSON
- Return rendered form to client
"""
if request.is_ajax():
super(DeleteView, self).get(request, *args, **kwargs)
data = {'id': self.get_object().id,
'delete': False,
'title': self.ajax_form_title,
'html_data': render_to_string(self.ajax_template_name,
self.get_context_data(),
request=request)
}
data = {
'id': self.get_object().id,
'delete': False,
'title': self.ajax_form_title,
'html_data': render_to_string(
self.ajax_template_name,
self.get_context_data(),
request=request)
}
return JsonResponse(data)
else:
return html_response
return JsonResponse(data)
def post(self, request, *args, **kwargs):
""" Respond to POST request
if request.is_ajax():
- DELETE the object
- Render success message to JSON and return to client
"""
obj = self.get_object()
pk = obj.id
obj.delete()
obj = self.get_object()
pk = obj.id
obj.delete()
data = {'id': pk,
'delete': True}
data = {
'id': pk,
'delete': True
}
return self.renderJsonResponse(request, data=data)
else:
return super(DeleteView, self).post(request, *args, **kwargs)
return self.renderJsonResponse(request, data=data)
class IndexView(TemplateView):

View File

@ -101,7 +101,10 @@ class BuildCreate(AjaxCreateView):
part_id = self.request.GET.get('part', None)
if part_id:
initials['part'] = get_object_or_404(Part, pk=part_id)
try:
initials['part'] = Part.objects.get(pk=part_id)
except Part.DoesNotExist:
pass
return initials

View File

@ -11,7 +11,6 @@ from rest_framework import generics, permissions
from django.db.models import Q
from django.conf.urls import url, include
from django.shortcuts import get_object_or_404
from .models import Part, PartCategory, BomItem
from .models import SupplierPart, SupplierPriceBreak
@ -99,20 +98,24 @@ class PartList(generics.ListCreateAPIView):
parts_list = Part.objects.all()
if cat_id:
category = get_object_or_404(PartCategory, pk=cat_id)
try:
category = PartCategory.objects.get(pk=cat_id)
# Filter by the supplied category
flt = Q(category=cat_id)
# Filter by the supplied category
flt = Q(category=cat_id)
if self.request.query_params.get('include_child_categories', None):
childs = category.getUniqueChildren()
for child in childs:
# Ignore the top-level category (already filtered)
if str(child) == str(cat_id):
continue
flt |= Q(category=child)
if self.request.query_params.get('include_child_categories', None):
childs = category.getUniqueChildren()
for child in childs:
# Ignore the top-level category (already filtered)
if str(child) == str(cat_id):
continue
flt |= Q(category=child)
parts_list = parts_list.filter(flt)
parts_list = parts_list.filter(flt)
except PartCategory.DoesNotExist:
pass
return parts_list

View File

@ -92,6 +92,8 @@ class EditBomItemForm(HelperForm):
'quantity',
'note'
]
# Prevent editing of the part associated with this BomItem
widgets = {'part': forms.HiddenInput()}
@ -101,13 +103,13 @@ class EditSupplierPartForm(HelperForm):
class Meta:
model = SupplierPart
fields = [
'part',
'supplier',
'SKU',
'part',
'description',
'URL',
'manufacturer',
'MPN',
'URL',
'note',
'single_price',
'base_cost',

View File

@ -0,0 +1,19 @@
# Generated by Django 2.2 on 2019-04-27 22:41
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('part', '0010_auto_20190417_0045'),
]
operations = [
migrations.AlterField(
model_name='supplierpart',
name='supplier',
field=models.ForeignKey(limit_choices_to={'is_supplier': True}, on_delete=django.db.models.deletion.CASCADE, related_name='parts', to='company.Company'),
),
]

View File

@ -84,7 +84,10 @@ class PartCreate(AjaxCreateView):
cat_id = self.get_category_id()
if cat_id:
context['category'] = get_object_or_404(PartCategory, pk=cat_id)
try:
context['category'] = PartCategory.objects.get(pk=cat_id)
except PartCategory.DoesNotExist:
pass
return context
@ -111,7 +114,10 @@ class PartCreate(AjaxCreateView):
initials = super(PartCreate, self).get_initial()
if self.get_category_id():
initials['category'] = get_object_or_404(PartCategory, pk=self.get_category_id())
try:
initials['category'] = PartCategory.objects.get(pk=self.get_category_id())
except PartCategory.DoesNotExist:
pass
return initials
@ -158,7 +164,6 @@ class PartEdit(AjaxUpdateView):
""" View for editing Part object """
model = Part
template_name = 'part/edit.html'
form_class = EditPartForm
ajax_template_name = 'modal_form.html'
ajax_form_title = 'Edit Part Properties'
@ -246,7 +251,6 @@ class PartDelete(AjaxDeleteView):
""" View to delete a Part object """
model = Part
template_name = 'part/delete.html'
ajax_template_name = 'part/partial_delete.html'
ajax_form_title = 'Confirm Part Deletion'
context_object_name = 'part'
@ -270,7 +274,6 @@ class CategoryDetail(DetailView):
class CategoryEdit(AjaxUpdateView):
""" Update view to edit a PartCategory """
model = PartCategory
template_name = 'part/category_edit.html'
form_class = EditCategoryForm
ajax_template_name = 'modal_form.html'
ajax_form_title = 'Edit Part Category'
@ -278,7 +281,10 @@ class CategoryEdit(AjaxUpdateView):
def get_context_data(self, **kwargs):
context = super(CategoryEdit, self).get_context_data(**kwargs).copy()
context['category'] = get_object_or_404(PartCategory, pk=self.kwargs['pk'])
try:
context['category'] = PartCategory.objects.get(pk=self.kwargs['pk'])
except:
pass
return context
@ -286,7 +292,7 @@ class CategoryEdit(AjaxUpdateView):
class CategoryDelete(AjaxDeleteView):
""" Delete view to delete a PartCategory """
model = PartCategory
template_name = 'part/category_delete.html'
ajax_template_name = 'part/category_delete.html'
context_object_name = 'category'
success_url = '/part/'
@ -302,7 +308,6 @@ class CategoryCreate(AjaxCreateView):
ajax_form_action = reverse_lazy('category-create')
ajax_form_title = 'Create new part category'
ajax_template_name = 'modal_form.html'
template_name = 'part/category_new.html'
form_class = EditCategoryForm
def get_context_data(self, **kwargs):
@ -315,7 +320,10 @@ class CategoryCreate(AjaxCreateView):
parent_id = self.request.GET.get('category', None)
if parent_id:
context['category'] = get_object_or_404(PartCategory, pk=parent_id)
try:
context['category'] = PartCategory.objects.get(pk=parent_id)
except PartCategory.DoesNotExist:
pass
return context
@ -329,7 +337,10 @@ class CategoryCreate(AjaxCreateView):
parent_id = self.request.GET.get('category', None)
if parent_id:
initials['parent'] = get_object_or_404(PartCategory, pk=parent_id)
try:
initials['parent'] = PartCategory.objects.get(pk=parent_id)
except PartCategory.DoesNotExist:
pass
return initials
@ -345,7 +356,6 @@ class BomItemCreate(AjaxCreateView):
""" Create view for making a new BomItem object """
model = BomItem
form_class = EditBomItemForm
template_name = 'part/bom-create.html'
ajax_template_name = 'modal_form.html'
ajax_form_title = 'Create BOM item'
@ -362,7 +372,10 @@ class BomItemCreate(AjaxCreateView):
parent_id = self.request.GET.get('parent', None)
if parent_id:
initials['part'] = get_object_or_404(Part, pk=parent_id)
try:
initials['part'] = Part.objects.get(pk=parent_id)
except Part.DoesNotExist:
pass
return initials
@ -372,7 +385,6 @@ class BomItemEdit(AjaxUpdateView):
model = BomItem
form_class = EditBomItemForm
template_name = 'part/bom-edit.html'
ajax_template_name = 'modal_form.html'
ajax_form_title = 'Edit BOM item'
@ -380,7 +392,7 @@ class BomItemEdit(AjaxUpdateView):
class BomItemDelete(AjaxDeleteView):
""" Delete view for removing BomItem """
model = BomItem
template_name = 'part/bom-delete.html'
ajax_template_name = 'part/bom-delete.html'
context_object_name = 'item'
ajax_form_title = 'Confim BOM item deletion'
@ -397,7 +409,6 @@ class SupplierPartEdit(AjaxUpdateView):
""" Update view for editing SupplierPart """
model = SupplierPart
template_name = 'company/partedit.html'
context_object_name = 'part'
form_class = EditSupplierPartForm
ajax_template_name = 'modal_form.html'
@ -413,6 +424,19 @@ class SupplierPartCreate(AjaxCreateView):
ajax_form_title = 'Create new Supplier Part'
context_object_name = 'part'
def get_form(self):
form = super(AjaxCreateView, self).get_form()
if form.initial.get('supplier', None):
# Hide the supplier field
form.fields['supplier'].widget.attrs['disabled'] = True
if form.initial.get('part', None):
# Hide the part field
form.fields['part'].widget.attrs['disabled'] = True
return form
def get_initial(self):
""" Provide initial data for new SupplierPart:
@ -421,18 +445,21 @@ class SupplierPartCreate(AjaxCreateView):
"""
initials = super(SupplierPartCreate, self).get_initial().copy()
supplier_id = self.request.GET.get('supplier', None)
part_id = self.request.GET.get('part', None)
supplier_id = self.get_param('supplier')
part_id = self.get_param('part')
if supplier_id:
initials['supplier'] = get_object_or_404(Company, pk=supplier_id)
# TODO
# self.fields['supplier'].disabled = True
try:
initials['supplier'] = Company.objects.get(pk=supplier_id)
except Company.DoesNotExist:
initials['supplier'] = None
if part_id:
initials['part'] = get_object_or_404(Part, pk=part_id)
# TODO
# self.fields['part'].disabled = True
try:
initials['part'] = Part.objects.get(pk=part_id)
except Part.DoesNotExist:
initials['part'] = None
return initials
@ -440,4 +467,4 @@ class SupplierPartDelete(AjaxDeleteView):
""" Delete view for removing a SupplierPart """
model = SupplierPart
success_url = '/supplier/'
template_name = 'company/partdelete.html'
ajax_template_name = 'company/partdelete.html'

View File

@ -7,7 +7,6 @@ from django_filters import NumberFilter
from django.conf.urls import url, include
from django.db.models import Q
from django.shortcuts import get_object_or_404
from .models import StockLocation, StockItem
from .models import StockItemTracking
@ -238,20 +237,24 @@ class StockList(generics.ListCreateAPIView):
stock_list = StockItem.objects.all()
if loc_id:
location = get_object_or_404(StockLocation, pk=loc_id)
try:
location = StockLocation.objects.get(pk=loc_id)
# Filter by the supplied category
flt = Q(location=loc_id)
# Filter by the supplied category
flt = Q(location=loc_id)
if self.request.query_params.get('include_child_locations', None):
childs = location.getUniqueChildren()
for child in childs:
# Ignore the top-level category (already filtered!)
if str(child) == str(loc_id):
continue
flt |= Q(location=child)
if self.request.query_params.get('include_child_locations', None):
childs = location.getUniqueChildren()
for child in childs:
# Ignore the top-level category (already filtered!)
if str(child) == str(loc_id):
continue
flt |= Q(location=child)
stock_list = stock_list.filter(flt)
stock_list = stock_list.filter(flt)
except StockLocation.DoesNotExist:
pass
return stock_list

View File

@ -0,0 +1,19 @@
# Generated by Django 2.2 on 2019-04-27 22:41
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('stock', '0008_auto_20190417_1819'),
]
operations = [
migrations.AlterField(
model_name='stockitem',
name='location',
field=models.ForeignKey(blank=True, help_text='Where is this stock item located?', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='stock_items', to='stock.StockLocation'),
),
]

View File

@ -5,8 +5,6 @@ Django views for interacting with Stock app
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.shortcuts import get_object_or_404
from django.views.generic import DetailView, ListView
from django.forms.models import model_to_dict
@ -106,7 +104,10 @@ class StockLocationCreate(AjaxCreateView):
loc_id = self.request.GET.get('location', None)
if loc_id:
initials['parent'] = get_object_or_404(StockLocation, pk=loc_id)
try:
initials['parent'] = StockLocation.objects.get(pk=loc_id)
except StockLocation.DoesNotExist:
pass
return initials
@ -125,7 +126,30 @@ class StockItemCreate(AjaxCreateView):
ajax_template_name = 'modal_form.html'
ajax_form_title = 'Create new Stock Item'
def get_form(self):
""" Get form for StockItem creation.
Overrides the default get_form() method to intelligently limit
ForeignKey choices based on other selections
"""
form = super(AjaxCreateView, self).get_form()
# If the user has selected a Part, limit choices for SupplierPart
if form['part'].value() is not None:
part = form['part'].value()
parts = form.fields['supplier_part'].queryset
parts = parts.filter(part=part)
form.fields['supplier_part'].queryset = parts
# Otherwise if the user has selected a SupplierPart, we know what Part they meant!
elif form['supplier_part'].value() is not None:
pass
return form
def get_initial(self):
""" Provide initial data to create a new StockItem object
"""
# Is the client attempting to copy an existing stock item?
item_to_copy = self.request.GET.get('copy', None)
@ -144,15 +168,22 @@ class StockItemCreate(AjaxCreateView):
part_id = self.request.GET.get('part', None)
loc_id = self.request.GET.get('location', None)
# Part field has been specified
if part_id:
part = get_object_or_404(Part, pk=part_id)
if part:
initials['part'] = get_object_or_404(Part, pk=part_id)
try:
part = Part.objects.get(pk=part_id)
initials['part'] = part
initials['location'] = part.default_location
initials['supplier_part'] = part.default_supplier
except Part.DoesNotExist:
pass
# Location has been specified
if loc_id:
initials['location'] = get_object_or_404(StockLocation, pk=loc_id)
try:
initials['location'] = StockLocation.objects.get(pk=loc_id)
except StockLocation.DoesNotExist:
pass
return initials
@ -178,7 +209,7 @@ class StockItemDelete(AjaxDeleteView):
model = StockItem
success_url = '/stock/'
template_name = 'stock/item_delete.html'
ajax_template_name = 'stock/item_delete.html'
context_object_name = 'item'
ajax_form_title = 'Delete Stock Item'
@ -190,7 +221,7 @@ class StockItemMove(AjaxUpdateView):
"""
model = StockItem
template_name = 'modal_form.html'
ajax_template_name = 'modal_form.html'
context_object_name = 'item'
ajax_form_title = 'Move Stock Item'
form_class = MoveStockItemForm

View File

@ -19,7 +19,8 @@ install:
pip install -U -r requirements.txt
python InvenTree/keygen.py
setup: install migrate
superuser:
python InvenTree/manage.py createsuperuser
style:
flake8 InvenTree
@ -37,5 +38,3 @@ documentation:
pip install -U -r docs/requirements.txt
cd docs & make html
superuser:
python InvenTree/manage.py createsuperuser

View File

@ -3,23 +3,54 @@
# 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
InvenTree is designed to be lightweight and easy to use for SME or hobbyist applications, where many existing stock management solutions are bloated and cumbersome to use. Updating stock is a single-action procses and does not require a complex system of work orders or stock transactions.
However, complex business logic works in the background to ensure that stock tracking history is maintained, and users have ready access to stock level information.
## User Documentation
**TODO:** User documentation will be provided on a linked ```.github.io``` site, and will document the various InvenTree functionality
## Code Documentation
For project code documentation, refer to the online [documentation](http://inventree.readthedocs.io/en/latest/) (auto-generated)
## Getting Started
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:
A makefile is provided for project configuration:
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
### Install
This command can also be used to update the installation if changes have been made to the database configuration.
Run `make install` to ensure that all required pip packages are installed (see `requirements.txt`). This step will also generate a `SECRET_KEY.txt` file (unless one already exists) for Django authentication.
To create an initial user account, run the command `make superuser`.
### Migrate
## Documentation
For project code documentation, refer to the online [documentation](http://inventree.readthedocs.io/en/latest/) (auto-generated)
Run `make migrate` to perform all pending database migrations to ensure the database schema is up to date.
## 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`.
**Note:** Run this step once after `make install` to create the initial empty database.
### Superuser
Run `make superuser` to create an admin account for the database
### Test
Run `make test` to run all code tests
### Style
Run `make style` to check the codebase against PEP coding standards (uses Flake)
## Contributing
### Testing
Any new functionality should be submitted with matching test cases (using the Django testing framework). Tests should at bare minimum ensure that any new Python code is covered by the integrated coverage testing. Tests can be run using `make test`.
### Coding Style
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.

View File

@ -7,6 +7,6 @@ This documentation is auto-generated from the `InvenTree codebase <https://githu
:titlesonly:
:maxdepth: 2
:caption: Contents:
:hidden:
InvenTree <introduction>
InvenTree Modules <introduction>
API Reference<reference>

View File

@ -6,11 +6,21 @@ InvenTree is an open source inventory management system which provides powerful
The core of the InvenTree software is a Python/Django database backend whi
Django Apps
===========
**Django Apps**
The InvenTree Django ecosystem provides the following 'apps' for core functionality:
.. toctree::
:titlesonly:
:maxdepth: 1
:caption: App Modules:
docs/InvenTree/index
docs/build/index
docs/company/index
docs/part/index
docs/stock/index
* `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)

7
docs/reference.rst Normal file
View File

@ -0,0 +1,7 @@
API Reference Index
===================
The complete reference indexes are found below:
* :ref:`modindex`
* :ref:`genindex`

View File

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