Compare commits

...

115 Commits

Author SHA1 Message Date
Oliver
e4dcbd2fda Update version.py
Bump version num
2019-09-09 14:25:00 +10:00
Oliver
b2c9be1bcd Merge pull request #494 from SchrodingersGat/update-fix
Ensure that required packages are installed when performing update step
2019-09-09 14:24:32 +10:00
Oliver Walters
da5f2338eb Ensure that required packages are installed when performing update step 2019-09-09 14:14:38 +10:00
Oliver
f5e34bef7e Merge pull request #492 from SchrodingersGat/fix
Fix uniqueness test for stock item
2019-09-09 08:53:20 +10:00
Oliver Walters
53c5324df6 Fix uniqueness test for stock item 2019-09-09 08:49:27 +10:00
Oliver
f9ae0f83d1 Merge pull request #491 from SchrodingersGat/improvement
Improvement
2019-09-09 08:34:39 +10:00
Oliver Walters
fb2c347fd4 Removed unused import 2019-09-09 08:30:24 +10:00
Oliver Walters
9c988310b6 Add tests for MPTT models 2019-09-09 08:29:36 +10:00
Oliver Walters
108382cc89 Prefecth related data for stock export
- Example export reduced from 1,024 queries to 7
2019-09-09 08:17:26 +10:00
Oliver
d8a3c7a81d Merge pull request #489 from SchrodingersGat/export-stocktake
Export stocktake
2019-09-09 00:05:05 +10:00
Oliver Walters
11c946be4d Export human-readable status code 2019-09-09 00:02:08 +10:00
Oliver Walters
fff42e7dbb Export stock based on part 2019-09-08 23:58:40 +10:00
Oliver Walters
231a669fe5 Export stock based on supplier 2019-09-08 23:53:09 +10:00
Oliver Walters
3d5542181a Move "Export" button onto stock table 2019-09-08 23:40:51 +10:00
Oliver Walters
e81a4ffacd Add docs for common modules 2019-09-08 23:36:25 +10:00
Oliver Walters
8817b4d692 Icer button rendering for PurchaseOrder page 2019-09-08 23:27:54 +10:00
Oliver Walters
fa8056f4b9 Fill out supplier_part details when receiving a line for a purchase order 2019-09-08 23:15:44 +10:00
Oliver Walters
9212d6047f Add supplier information to exported data 2019-09-08 23:10:13 +10:00
Oliver Walters
cc452bc270 Export stock data 2019-09-08 23:01:16 +10:00
Oliver Walters
faf8b9f2f0 Form / view for downloading stocktake info 2019-09-08 22:37:27 +10:00
Oliver Walters
f4e71d6055 Add a buttony-boy
(cherry picked from commit 69ac5d870a2f1bc9589cd9b23212d3b51cf92c80)
2019-09-08 20:55:19 +10:00
Oliver Walters
2c969ef1c6 View for exporting stocktake / stock list
(cherry picked from commit bdad2d6178a14322ef225d08b13db86b6d7d0909)
2019-09-08 20:55:06 +10:00
Oliver
2f47140e0f Merge pull request #488 from SchrodingersGat/mptt
Mptt
2019-09-08 20:51:11 +10:00
Oliver Walters
026108803e More links in the about page 2019-09-08 20:48:33 +10:00
Oliver Walters
d7f969613e Update makefile and docs 2019-09-08 20:45:01 +10:00
Oliver Walters
e4fc44c135 More test 2019-09-08 20:36:51 +10:00
Oliver Walters
2a203be5cc Tests for part parameters 2019-09-08 20:18:21 +10:00
Oliver Walters
35ebc69235 Backup database as part of the migration process 2019-09-08 19:43:10 +10:00
Oliver Walters
dac61eafa2 Fixed tests
- Tree classes now need extra configuration in the fixture
- Check for null pk when cleaning a tree node
2019-09-08 19:41:54 +10:00
Oliver Walters
a5189b8f3f Replace a recursive function 2019-09-08 19:28:40 +10:00
Oliver Walters
0d6a3d3b28 BOM table now refreshes the table rather than the page 2019-09-08 19:24:47 +10:00
Oliver Walters
b554af5f10 Fix display of location list 2019-09-08 19:21:40 +10:00
Oliver Walters
3eb3c43e5c Change foreign keys to TreeForeignKey 2019-09-08 19:19:39 +10:00
Oliver Walters
678157aac4 Update StockLocation and PartCategory models
- Use the MPTT functionality once more
2019-09-08 19:13:13 +10:00
Oliver Walters
4d7fba9f14 Replace tree functionality with MPTT goodness 2019-09-08 18:57:48 +10:00
Oliver Walters
2f11fccb73 Migrate InvenTreeTree to using MPTT model 2019-09-08 14:08:49 +10:00
Oliver Walters
b3a5dbb5db Add django-mptt as a requirement
(cherry picked from commit 8c33a9fca11ad9af9c9f1c6ddf2a9dab8d71e2e4)
2019-09-08 14:02:30 +10:00
Oliver
ce706aab9e Merge pull request #485 from SchrodingersGat/token-tests
Tests for retrieving user auth tokens
2019-09-08 00:37:16 +10:00
Oliver Walters
baf096b3e7 Ensure token validation is working correctly 2019-09-08 00:28:12 +10:00
Oliver Walters
576226ad30 Tests for retrieving user auth tokens 2019-09-07 23:41:15 +10:00
Oliver
dfb0f67b87 Merge pull request #484 from SchrodingersGat/leven
Install package for fast string matching
2019-09-07 23:40:28 +10:00
Oliver Walters
32f606627d Special display case for base currecny 2019-09-07 22:43:39 +10:00
Oliver Walters
f24496c5a2 Enforce at least one base currency to be selected 2019-09-07 22:42:08 +10:00
Oliver Walters
654fbc3847 Ensure migrations are always called from the correct directory 2019-09-07 22:41:57 +10:00
Oliver Walters
e1ef7174f9 Install package for fast string matching
- But really, mostly to supress a warning!
2019-09-07 22:33:35 +10:00
Oliver
27798cd4ad Merge pull request #482 from SchrodingersGat/tweakies
Various UI Tweaks
2019-09-07 21:22:32 +10:00
Oliver Walters
023c386f5e Display a warning message if delete_on_deplete is set 2019-09-07 21:18:32 +10:00
Oliver Walters
b4bbd43bae Fix 404 if a stock item is completely depleted 2019-09-07 21:15:14 +10:00
Oliver Walters
efc08f6824 Improve table sorting for company list 2019-09-07 20:47:02 +10:00
Oliver
dd5ca32e8e Merge pull request #481 from SchrodingersGat/settings
Improve settings view
2019-09-07 20:37:03 +10:00
Oliver
daa5a32440 Merge pull request #447 from rrakso/feature/add_method_to_delete_token
Feature/add method to delete token
2019-09-07 20:36:39 +10:00
Oliver Walters
446b342480 Add pagination to tables 2019-09-07 20:32:22 +10:00
Oliver Walters
c45fcb45cf Delete a part parameter template from the settings view 2019-09-07 20:30:51 +10:00
Oliver Walters
42ade0e0b7 Edit part parameter template from settings view 2019-09-07 20:28:38 +10:00
Oliver Walters
55669c79c2 Delete a currency from the currency settings view 2019-09-07 20:22:30 +10:00
Oliver Walters
3188b0ab18 Edit currency from settings view 2019-09-07 20:19:35 +10:00
Oliver Walters
31562826f4 Add modal form for creating a new currency 2019-09-07 20:06:04 +10:00
Oliver Walters
67ea0fa887 Create a new part parameter template 2019-09-07 19:53:47 +10:00
Oliver Walters
94ab7c5b0e Display list of part parameter templates in the part settings page 2019-09-07 19:45:36 +10:00
Oliver Walters
f415e2040e API endpoint for PartParameter and PartParameterTemplate 2019-09-07 19:44:10 +10:00
Oliver Walters
13270617b9 Ensure PartParameterTemplate name is unique 2019-09-07 19:43:41 +10:00
Oliver Walters
6752bdc1c6 Sort currency API 2019-09-07 19:28:20 +10:00
Oliver Walters
873faee040 Display currency list under currency settings page 2019-09-07 19:23:58 +10:00
Oliver Walters
9726ea4f99 Add serializer / API for currency objects 2019-09-07 19:18:18 +10:00
Oliver Walters
89c3ab5e99 Formatting 2019-09-07 18:58:37 +10:00
Oliver Walters
27878d2d8d Split settings into multiple pages
- Tab style navigation
2019-09-07 18:02:03 +10:00
Oliver Walters
1b8fb4db44 Start skeleton for better settings page 2019-09-07 15:04:18 +10:00
Oliver
9a61ba4e1e Merge pull request #479 from SchrodingersGat/hide-sellable
Hide the 'sellable' tag for now
2019-09-07 14:25:33 +10:00
Oliver Walters
9c864aa619 Hide the 'sellable' tag for now
- Keep hidden until parts can actually be sold
2019-09-07 10:51:39 +10:00
Oliver
e9ba51da52 Merge pull request #478 from SchrodingersGat/show-batch
Display batch information in stock table
2019-09-07 10:44:15 +10:00
Oliver Walters
8703ee90c6 Improve grouping 2019-09-07 10:41:22 +10:00
Oliver Walters
910d9a15f6 Display batch information in stock table 2019-09-07 10:39:48 +10:00
Oliver
b3be5ca5a0 Merge pull request #474 from SchrodingersGat/qr-png
Fixes
2019-09-06 13:19:19 +10:00
Oliver Walters
dac1264878 Fixed unit tests 2019-09-06 12:48:31 +10:00
Oliver Walters
9bde8bde66 Tree items dispaly description in __str__ representation 2019-09-06 12:40:04 +10:00
Oliver Walters
eb378e5e5d Specify PNG image for QR code generation 2019-09-06 12:38:09 +10:00
Oliver
4ff7920296 Merge pull request #473 from SchrodingersGat/attachment-buttons
Bug fix
2019-09-05 20:30:25 +10:00
Oliver Walters
38b88e44bd Bug fix 2019-09-05 20:22:47 +10:00
Oliver
7642a1bb7b Merge pull request #472 from SchrodingersGat/default-loc
Add new option to move-stock form
2019-09-05 20:09:47 +10:00
Oliver Walters
7fd4359007 Add new option to move-stock form
- Set the destination as the default location for parts being moved
2019-09-05 19:59:00 +10:00
Oliver
8fe7284173 Merge pull request #471 from SchrodingersGat/bom-checksum
Bom checksum
2019-09-05 19:42:11 +10:00
Oliver Walters
7659f2de7b Click button to validate BOM item 2019-09-05 19:34:58 +10:00
Oliver Walters
37d9c59a0e Add API endpoint for validating a BOM item 2019-09-05 19:29:51 +10:00
Oliver Walters
81f5714cb1 BOM table now displays which lines have been marked as valid 2019-09-05 14:15:58 +10:00
Oliver Walters
1ea7bdf843 Add a note 2019-09-05 13:12:49 +10:00
Oliver Walters
0508c2dcaf Use the hash for each line item to calculate the total BOM hash 2019-09-05 13:10:26 +10:00
Oliver Walters
7671eb2b22 Add a checksum field to the bom line item 2019-09-05 12:58:11 +10:00
Oliver
c96c4d16a3 Merge pull request #469 from SchrodingersGat/update-docs
Add documentation on perfoming system upgrades
2019-09-04 10:56:57 +10:00
Oliver Walters
7ef2932f38 Add documentation on perfoming system upgrades 2019-09-04 10:54:15 +10:00
Oliver
ecd1681585 Merge pull request #468 from SchrodingersGat/currency-quote
Currency quote
2019-09-03 23:16:12 +10:00
Oliver Walters
e903c1858f PEP 2019-09-03 22:45:45 +10:00
Oliver Walters
20b37a2d11 Test fixes 2019-09-03 22:45:11 +10:00
Oliver Walters
41806089e3 Select the default currency if one is not specifically selected 2019-09-03 22:33:50 +10:00
Oliver Walters
3682e9b5fb Display currency selection in part pricing dialog 2019-09-03 22:28:53 +10:00
Oliver Walters
7314f33d6d Add currency selection field for price calculation form 2019-09-03 22:00:43 +10:00
Oliver
8ae19cb095 Merge pull request #467 from SchrodingersGat/version
Bump version number
2019-09-03 10:41:49 +10:00
Oliver Walters
0325f042b5 Bump version number 2019-09-03 09:59:14 +10:00
Oliver
a6ee3a59a0 Merge pull request #466 from SchrodingersGat/currency
Currency
2019-09-03 09:58:22 +10:00
Oliver Walters
af8a96e080 Add option to edit currency 2019-09-03 09:55:15 +10:00
Oliver Walters
09cb82cdc0 Fix converted_cost
- Incompatibility between float and decimal
2019-09-03 09:46:32 +10:00
Oliver Walters
c6a435eba0 Add currency field to SupplierPriceBreak 2019-09-03 09:34:32 +10:00
Oliver Walters
9f91797f42 Simple test case for currency 2019-09-03 09:19:37 +10:00
Oliver Walters
32d09d2d37 Add default value for currency 2019-09-03 09:10:36 +10:00
Oliver Walters
7824b8561d Create a currency model 2019-09-03 09:07:03 +10:00
Oliver Walters
aeb25e4c34 startapp common 2019-09-03 08:30:14 +10:00
Oliver
9f87963fa9 Merge pull request #465 from SchrodingersGat/bug-squashin
Squashin' some bugs
2019-09-02 22:21:44 +10:00
Oliver Walters
273412b63d Add 'bug report' button 2019-09-02 22:14:50 +10:00
Oliver Walters
f3e161564d Fix for serialization of company images 2019-09-02 22:06:42 +10:00
Oskar Jaskolski
8949542baf fixed response text 2019-08-28 12:53:08 +02:00
Oskar Jaskolski
0a6abd21be fixed responde text 2019-08-28 12:47:56 +02:00
Oskar Jaskolski
3c70c3a29c clean 2019-08-28 12:41:46 +02:00
Oskar Jaskolski
981884f368 test 2019-08-28 12:40:06 +02:00
Oskar Jaskolski
5dcfc20d82 test 2019-08-28 12:39:25 +02:00
Oskar Jaskolski
381e58ab1c added import 2019-08-28 12:35:00 +02:00
Oskar Jaskolski
105b93a0e3 added method allowing to remove token 2019-08-28 12:30:31 +02:00
95 changed files with 2144 additions and 235 deletions

2
.gitignore vendored
View File

@@ -34,6 +34,8 @@ docs/_build
# Local static and media file storage (only when running in development mode)
InvenTree/media
InvenTree/static
media
static
# Local config file
config.yaml

View File

@@ -94,6 +94,18 @@ def MakeBarcode(object_type, object_id, object_url, data={}):
return json.dumps(data, sort_keys=True)
def GetExportFormats():
""" Return a list of allowable file formats for exporting data """
return [
'csv',
'tsv',
'xls',
'xlsx',
'json',
]
def DownloadFile(data, filename, content_type='application/text'):
""" Create a dynamic file for the user to download.

View File

@@ -6,15 +6,16 @@ from __future__ import unicode_literals
from django.db import models
from django.contrib.contenttypes.models import ContentType
from rest_framework.exceptions import ValidationError
from django.db.models.signals import pre_delete
from django.dispatch import receiver
from mptt.models import MPTTModel, TreeForeignKey
from .validators import validate_tree_name
class InvenTreeTree(models.Model):
class InvenTreeTree(MPTTModel):
""" 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).
@@ -30,6 +31,9 @@ class InvenTreeTree(models.Model):
abstract = True
unique_together = ('name', 'parent')
class MPTTMeta:
order_insertion_by = ['name']
name = models.CharField(
blank=False,
max_length=100,
@@ -43,11 +47,11 @@ class InvenTreeTree(models.Model):
)
# When a category is deleted, graft the children onto its parent
parent = models.ForeignKey('self',
on_delete=models.DO_NOTHING,
blank=True,
null=True,
related_name='children')
parent = TreeForeignKey('self',
on_delete=models.DO_NOTHING,
blank=True,
null=True,
related_name='children')
@property
def item_count(self):
@@ -60,59 +64,31 @@ class InvenTreeTree(models.Model):
"""
return 0
def getUniqueParents(self, unique=None):
def getUniqueParents(self):
""" 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
"""
item = self
return self.get_ancestors()
# Prevent infinite regression
max_parents = 500
unique = set()
while item.parent and max_parents > 0:
max_parents -= 1
unique.add(item.parent.id)
item = item.parent
return unique
def getUniqueChildren(self, unique=None, include_self=True):
def getUniqueChildren(self, include_self=True):
""" 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
if include_self:
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
return self.get_descendants(include_self=include_self)
@property
def has_children(self):
""" True if there are any children under this item """
return self.children.count() > 0
return self.getUniqueChildren(include_self=False).count() > 0
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()
@@ -136,10 +112,7 @@ class InvenTreeTree(models.Model):
List of category names from the top level to the parent of this category
"""
if self.parent:
return self.parent.parentpath + [self.parent]
else:
return []
return [a for a in self.get_ancestors()]
@property
def path(self):
@@ -160,36 +133,10 @@ class InvenTreeTree(models.Model):
"""
return '/'.join([item.name for item in self.path])
def clean(self):
""" Custom cleaning
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
"""
super().clean()
# Parent cannot be set to same ID (this would cause looping)
try:
if self.parent.id == self.id:
raise ValidationError("Category cannot set itself as parent")
except:
pass
# Ensure that the new parent is not already a child
if self.id in self.getUniqueChildren(include_self=False):
raise ValidationError("Category cannot set a child as parent")
def __str__(self):
""" String representation of a category is the full path to that category """
return self.pathstring
return "{path} - {desc}".format(path=self.pathstring, desc=self.description)
@receiver(pre_delete, sender=InvenTreeTree, dispatch_uid='tree_pre_delete_log')

View File

@@ -58,9 +58,7 @@ cors_opt = CONFIG.get('cors', None)
if cors_opt:
CORS_ORIGIN_ALLOW_ALL = cors_opt.get('allow_all', False)
if CORS_ORIGIN_ALLOW_ALL:
eprint("Warning: CORS requests are allowed for any domain!")
else:
if not CORS_ORIGIN_ALLOW_ALL:
CORS_ORIGIN_WHITELIST = cors_opt.get('whitelist', [])
if DEBUG:
@@ -83,6 +81,7 @@ INSTALLED_APPS = [
'django.contrib.staticfiles',
# InvenTree apps
'common.apps.CommonConfig',
'part.apps.PartConfig',
'stock.apps.StockConfig',
'company.apps.CompanyConfig',
@@ -99,6 +98,7 @@ INSTALLED_APPS = [
'import_export', # Import / export tables to file
'django_cleanup', # Automatically delete orphaned MEDIA files
'qr_code', # Generate QR codes
'mptt', # Modified Preorder Tree Traversal
]
LOGGING = {

View File

@@ -1,3 +1,14 @@
.qr-code {
max-width: 400px;
max-height: 400px;
align-content: center;
}
.qr-container {
width: 100%;
align-content: center;
}
.navbar-brand {
float: left;
}
@@ -77,10 +88,25 @@
width: 100%;
}
.basecurrency {
color: #050;
font-style: italic;
font-weight: bold;
}
.bomselect {
max-width: 250px;
}
.bomrowvalid {
color: #050;
}
.bomrowinvalid {
color: #A00;
font-style: italic;
}
/* Part image icons with full-display on mouse hover */
.hover-img-thumb {
@@ -178,6 +204,28 @@
margin-bottom: 20px;
}
.settings-container {
width: 90%;
padding: 15px;
}
.settings-nav {
height: 100%;
width: 160px;
position: fixed;
z-index: 1;
//top: 0;
//left: 0;
overflow-x: hidden;
padding-top: 20px;
padding-right: 25px;
}
.settings-content {
margin-left: 175px;
padding: 0px 10px;
}
.breadcrump {
margin-bottom: 5px;
}

View File

@@ -113,14 +113,19 @@ function loadBomTable(table, options) {
];
if (options.editable) {
/*
// TODO - Enable multi-select functionality
cols.push({
checkbox: true,
title: 'Select',
searchable: false,
sortable: false,
});
*/
}
// Part column
cols.push(
{
@@ -230,10 +235,27 @@ function loadBomTable(table, options) {
if (options.editable) {
cols.push({
formatter: function(value, row, index, field) {
var bValidate = "<button title='Validate BOM Item' class='bom-validate-button btn btn-default btn-glyph' type='button' pk='" + row.pk + "'><span class='glyphicon glyphicon-check'/></button>";
var bValid = "<span class='glyphicon glyphicon-ok'/>";
var bEdit = "<button title='Edit BOM Item' class='bom-edit-button btn btn-default btn-glyph' type='button' url='/part/bom/" + row.pk + "/edit'><span class='glyphicon glyphicon-edit'/></button>";
var bDelt = "<button title='Delete BOM Item' class='bom-delete-button btn btn-default btn-glyph' type='button' url='/part/bom/" + row.pk + "/delete'><span class='glyphicon glyphicon-trash'/></button>";
return "<div class='btn-group' role='group'>" + bEdit + bDelt + "</div>";
var html = "<div class='btn-group' role='group'>";
html += bEdit;
html += bDelt;
if (!row.validated) {
html += bValidate;
} else {
html += bValid;
}
html += "</div>";
return html;
}
});
}
@@ -256,6 +278,13 @@ function loadBomTable(table, options) {
table.bootstrapTable({
sortable: true,
search: true,
rowStyle: function(row, index) {
if (row.validated) {
return {classes: 'bomrowvalid'};
} else {
return {classes: 'bomrowinvalid'};
}
},
formatNoMatches: function() { return "No BOM items found"; },
clickToSelect: true,
showFooter: true,
@@ -288,5 +317,24 @@ function loadBomTable(table, options) {
}
});
});
table.on('click', '.bom-validate-button', function() {
var button = $(this);
var url = '/api/bom/' + button.attr('pk') + '/validate/';
inventreePut(
url,
{
valid: true
},
{
method: 'PATCH',
success: function() {
reloadBomTable(table);
}
}
);
});
}
}

View File

@@ -76,12 +76,40 @@ function loadStockTable(table, options) {
}
else if (field == 'quantity') {
var stock = 0;
var items = 0;
data.forEach(function(item) {
stock += item.quantity;
items += 1;
});
return stock;
return stock + " (" + items + " items)";
} else if (field == 'batch') {
var batches = [];
data.forEach(function(item) {
var batch = item.batch;
if (!batch || batch == '') {
batch = '-';
}
if (!batches.includes(batch)) {
batches.push(batch);
}
});
if (batches.length > 1) {
return "" + batches.length + " batches";
} else if (batches.length == 1) {
if (batches[0]) {
return batches[0];
} else {
return '-';
}
} else {
return '-';
}
} else if (field == 'location__path') {
/* Determine how many locations */
var locations = [];
@@ -165,6 +193,11 @@ function loadStockTable(table, options) {
return text;
}
},
{
field: 'batch',
title: 'Batch',
sortable: true,
},
{
field: 'location__path',
title: 'Location',

View File

@@ -10,7 +10,7 @@ class StatusCode:
@classmethod
def label(cls, value):
""" Return the status code label associated with the provided value """
return cls.options.get(value, '')
return cls.options.get(value, value)
class OrderStatus(StatusCode):

View File

@@ -0,0 +1,67 @@
""" Low level tests for the InvenTree API """
from rest_framework.test import APITestCase
from rest_framework import status
from django.urls import reverse
from django.contrib.auth import get_user_model
class APITests(APITestCase):
""" Tests for the InvenTree API """
fixtures = [
'location',
'stock',
'part',
'category',
]
username = 'test_user'
password = 'test_pass'
def setUp(self):
# Create a user (but do not log in!)
User = get_user_model()
User.objects.create_user(self.username, 'user@email.com', self.password)
def test_get_token_fail(self):
""" Ensure that an invalid user cannot get a token """
token_url = reverse('api-token')
response = self.client.post(token_url, format='json', data={'username': 'bad', 'password': 'also_bad'})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertFalse('token' in response.data)
def test_get_token_pass(self):
""" Ensure that a valid user can request an API token """
token_url = reverse('api-token')
# POST to retreive a token
response = self.client.post(token_url, format='json', data={'username': self.username, 'password': self.password})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue('token' in response.data)
self.assertTrue('pk' in response.data)
self.assertTrue(len(response.data['token']) > 0)
# Now, use the token to access other data
token = response.data['token']
part_url = reverse('api-part-list')
# Try to access without a token
response = self.client.get(part_url, format='json')
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
# Now, with the token
self.client.credentials(HTTP_AUTHORIZATION='Token ' + token)
response = self.client.get(part_url, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)

View File

@@ -10,13 +10,16 @@ import os
class ViewTests(TestCase):
""" Tests for various top-level views """
username = 'test_user'
password = 'test_pass'
def setUp(self):
# Create a user
User = get_user_model()
User.objects.create_user('username', 'user@email.com', 'password')
User.objects.create_user(self.username, 'user@email.com', self.password)
self.client.login(username='username', password='password')
self.client.login(username=self.username, password=self.password)
def test_api_doc(self):
""" Test that the api-doc view works """

View File

@@ -5,6 +5,10 @@ from django.core.exceptions import ValidationError
from .validators import validate_overage, validate_part_name
from . import helpers
from mptt.exceptions import InvalidMove
from stock.models import StockLocation
class ValidatorTest(TestCase):
@@ -78,7 +82,7 @@ class TestQuoteWrap(TestCase):
self.assertEqual(helpers.WrapWithQuotes('hello"'), '"hello"')
class TestMakeBarcoede(TestCase):
class TestMakeBarcode(TestCase):
""" Tests for barcode string creation """
def test_barcode(self):
@@ -103,6 +107,54 @@ class TestDownloadFile(TestCase):
helpers.DownloadFile(bytes("hello world".encode("utf8")), "out.bin")
class TestMPTT(TestCase):
""" Tests for the MPTT tree models """
fixtures = [
'location',
]
def setUp(self):
super().setUp()
StockLocation.objects.rebuild()
def test_self_as_parent(self):
""" Test that we cannot set self as parent """
loc = StockLocation.objects.get(pk=4)
loc.parent = loc
with self.assertRaises(InvalidMove):
loc.save()
def test_child_as_parent(self):
""" Test that we cannot set a child as parent """
parent = StockLocation.objects.get(pk=4)
child = StockLocation.objects.get(pk=5)
parent.parent = child
with self.assertRaises(InvalidMove):
parent.save()
def test_move(self):
""" Move an item to a different tree """
drawer = StockLocation.objects.get(name='Drawer_1')
# Record the tree ID
tree = drawer.tree_id
home = StockLocation.objects.get(name='Home')
drawer.parent = home
drawer.save()
self.assertNotEqual(tree, drawer.tree_id)
class TestSerialNumberExtraction(TestCase):
""" Tests for serial number extraction code """

View File

@@ -14,11 +14,13 @@ from company.urls import company_urls
from company.urls import supplier_part_urls
from company.urls import price_break_urls
from common.urls import common_urls
from part.urls import part_urls
from stock.urls import stock_urls
from build.urls import build_urls
from order.urls import order_urls
from common.api import common_api_urls
from part.api import part_api_urls, bom_api_urls
from company.api import company_api_urls
from stock.api import stock_api_urls
@@ -39,6 +41,7 @@ from users.urls import user_urls
admin.site.site_header = "InvenTree Admin"
apipatterns = [
url(r'^common/', include(common_api_urls)),
url(r'^part/', include(part_api_urls)),
url(r'^bom/', include(bom_api_urls)),
url(r'^company/', include(company_api_urls)),
@@ -53,11 +56,23 @@ apipatterns = [
url(r'^$', InfoView.as_view(), name='inventree-info'),
]
settings_urls = [
url(r'^user/?', SettingsView.as_view(template_name='InvenTree/settings/user.html'), name='settings-user'),
url(r'^currency/?', SettingsView.as_view(template_name='InvenTree/settings/currency.html'), name='settings-currency'),
url(r'^part/?', SettingsView.as_view(template_name='InvenTree/settings/part.html'), name='settings-part'),
# Catch any other urls
url(r'^.*$', SettingsView.as_view(template_name='InvenTree/settings/user.html'), name='settings'),
]
urlpatterns = [
url(r'^part/', include(part_urls)),
url(r'^supplier-part/', include(supplier_part_urls)),
url(r'^price-break/', include(price_break_urls)),
url(r'^common/', include(common_urls)),
url(r'^stock/', include(stock_urls)),
url(r'^company/', include(company_urls)),
@@ -70,7 +85,7 @@ urlpatterns = [
url(r'^login/', auth_views.LoginView.as_view(), name='login'),
url(r'^logout/', auth_views.LogoutView.as_view(template_name='registration/logout.html'), name='logout'),
url(r'^settings/', SettingsView.as_view(), name='settings'),
url(r'^settings/', include(settings_urls)),
url(r'^edit-user/', EditUserView.as_view(), name='edit-user'),
url(r'^set-password/', SetPasswordView.as_view(), name='set-password'),

View File

@@ -4,7 +4,7 @@ Provides information on the current InvenTree version
import subprocess
INVENTREE_SW_VERSION = "0.0.3"
INVENTREE_SW_VERSION = "0.0.5"
def inventreeVersion():

View File

10
InvenTree/common/admin.py Normal file
View File

@@ -0,0 +1,10 @@
from django.contrib import admin
from .models import Currency
class CurrencyAdmin(admin.ModelAdmin):
list_display = ('symbol', 'suffix', 'description', 'value', 'base')
admin.site.register(Currency, CurrencyAdmin)

39
InvenTree/common/api.py Normal file
View File

@@ -0,0 +1,39 @@
"""
Provides a JSON API for common components.
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from rest_framework import permissions, generics, filters
from django.conf.urls import url
from .models import Currency
from .serializers import CurrencySerializer
class CurrencyList(generics.ListCreateAPIView):
""" API endpoint for accessing a list of Currency objects.
- GET: Return a list of Currencies
- POST: Create a new currency
"""
queryset = Currency.objects.all()
serializer_class = CurrencySerializer
permission_classes = [
permissions.IsAuthenticated,
]
filter_backends = [
filters.OrderingFilter,
]
ordering_fields = ['suffix', 'value']
common_api_urls = [
url(r'^currency/?$', CurrencyList.as_view(), name='api-currency-list'),
]

5
InvenTree/common/apps.py Normal file
View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class CommonConfig(AppConfig):
name = 'common'

View File

@@ -0,0 +1,16 @@
# Test fixtures for Currency objects
- model: common.currency
fields:
symbol: '$'
suffix: 'AUD'
description: 'Australian Dollars'
base: True
- model: common.currency
fields:
symbol: '$'
suffix: 'USD'
description: 'US Dollars'
base: False
value: 1.4

24
InvenTree/common/forms.py Normal file
View File

@@ -0,0 +1,24 @@
"""
Django forms for interacting with common objects
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from InvenTree.forms import HelperForm
from .models import Currency
class CurrencyEditForm(HelperForm):
""" Form for creating / editing a currency object """
class Meta:
model = Currency
fields = [
'symbol',
'suffix',
'description',
'value',
'base'
]

View File

@@ -0,0 +1,26 @@
# Generated by Django 2.2.4 on 2019-09-02 23:02
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Currency',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('symbol', models.CharField(help_text='Currency Symbol e.g. $', max_length=10)),
('suffix', models.CharField(help_text='Currency Suffix e.g. AUD', max_length=10, unique=True)),
('description', models.CharField(help_text='Currency Description', max_length=100)),
('value', models.DecimalField(decimal_places=5, help_text='Currency Value', max_digits=10, validators=[django.core.validators.MinValueValidator(1e-05), django.core.validators.MaxValueValidator(100000)])),
('base', models.BooleanField(default=False, help_text='Use this currency as the base currency')),
],
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 2.2.4 on 2019-09-02 23:04
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('common', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='currency',
options={'verbose_name_plural': 'Currencies'},
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 2.2.4 on 2019-09-02 23:10
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('common', '0002_auto_20190902_2304'),
]
operations = [
migrations.AlterField(
model_name='currency',
name='value',
field=models.DecimalField(decimal_places=5, default=1.0, help_text='Currency Value', max_digits=10, validators=[django.core.validators.MinValueValidator(1e-05), django.core.validators.MaxValueValidator(100000)]),
),
]

View File

View File

@@ -0,0 +1,79 @@
"""
Common database model definitions.
These models are 'generic' and do not fit a particular business logic object.
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models
from django.utils.translation import ugettext as _
from django.core.validators import MinValueValidator, MaxValueValidator
class Currency(models.Model):
"""
A Currency object represents a particular unit of currency.
Each Currency has a scaling factor which relates it to the base currency.
There must be one (and only one) currency which is selected as the base currency,
and each other currency is calculated relative to it.
Attributes:
symbol: Currency symbol e.g. $
suffix: Currency suffix e.g. AUD
description: Long-form description e.g. "Australian Dollars"
value: The value of this currency compared to the base currency.
base: True if this currency is the base currency
"""
symbol = models.CharField(max_length=10, blank=False, unique=False, help_text=_('Currency Symbol e.g. $'))
suffix = models.CharField(max_length=10, blank=False, unique=True, help_text=_('Currency Suffix e.g. AUD'))
description = models.CharField(max_length=100, blank=False, help_text=_('Currency Description'))
value = models.DecimalField(default=1.0, max_digits=10, decimal_places=5, validators=[MinValueValidator(0.00001), MaxValueValidator(100000)], help_text=_('Currency Value'))
base = models.BooleanField(default=False, help_text=_('Use this currency as the base currency'))
class Meta:
verbose_name_plural = 'Currencies'
def __str__(self):
""" Format string for currency representation """
s = "{sym} {suf} - {desc}".format(
sym=self.symbol,
suf=self.suffix,
desc=self.description
)
if self.base:
s += " (Base)"
else:
s += " = {v}".format(v=self.value)
return s
def save(self, *args, **kwargs):
""" Validate the model before saving
- Ensure that there is only one base currency!
"""
# If this currency is set as the base currency, ensure no others are
if self.base:
for cur in Currency.objects.filter(base=True).exclude(pk=self.pk):
cur.base = False
cur.save()
# If there are no currencies set as the base currency, set this as base
if not Currency.objects.exclude(pk=self.pk).filter(base=True).exists():
self.base = True
# If this is the base currency, ensure value is set to unity
if self.base:
self.value = 1.0
super().save(*args, **kwargs)

View File

@@ -0,0 +1,22 @@
"""
JSON serializers for common components
"""
from .models import Currency
from InvenTree.serializers import InvenTreeModelSerializer
class CurrencySerializer(InvenTreeModelSerializer):
""" Serializer for Currency object """
class Meta:
model = Currency
fields = [
'pk',
'symbol',
'suffix',
'description',
'value',
'base'
]

View File

@@ -0,0 +1,7 @@
{% extends "modal_delete_form.html" %}
{% block pre_form_content %}
Are you sure you wish to delete this currency?
{% endblock %}

19
InvenTree/common/tests.py Normal file
View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.test import TestCase
from .models import Currency
class CurrencyTest(TestCase):
""" Tests for Currency model """
fixtures = [
'currency',
]
def test_currency(self):
# Simple test for now (improve this later!)
self.assertEqual(Currency.objects.count(), 2)

18
InvenTree/common/urls.py Normal file
View File

@@ -0,0 +1,18 @@
"""
URL lookup for common views
"""
from django.conf.urls import url, include
from . import views
currency_urls = [
url(r'^new/', views.CurrencyCreate.as_view(), name='currency-create'),
url(r'^(?P<pk>\d+)/edit/', views.CurrencyEdit.as_view(), name='currency-edit'),
url(r'^(?P<pk>\d+)/delete/', views.CurrencyDelete.as_view(), name='currency-delete'),
]
common_urls = [
url(r'currency/', include(currency_urls)),
]

35
InvenTree/common/views.py Normal file
View File

@@ -0,0 +1,35 @@
"""
Django views for interacting with common models
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from InvenTree.views import AjaxCreateView, AjaxUpdateView, AjaxDeleteView
from . import models
from . import forms
class CurrencyCreate(AjaxCreateView):
""" View for creating a new Currency object """
model = models.Currency
form_class = forms.CurrencyEditForm
ajax_form_title = 'Create new Currency'
class CurrencyEdit(AjaxUpdateView):
""" View for editing an existing Currency object """
model = models.Currency
form_class = forms.CurrencyEditForm
ajax_form_title = 'Edit Currency'
class CurrencyDelete(AjaxDeleteView):
""" View for deleting an existing Currency object """
model = models.Currency
ajax_form_title = 'Delete Currency'
ajax_template_name = "common/delete_currency.html"

View File

@@ -70,5 +70,6 @@ class EditPriceBreakForm(HelperForm):
fields = [
'part',
'quantity',
'cost'
'cost',
'currency',
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 2.2.4 on 2019-09-02 23:34
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('common', '0003_auto_20190902_2310'),
('company', '0005_auto_20190525_2356'),
]
operations = [
migrations.AddField(
model_name='supplierpricebreak',
name='currency',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='common.Currency'),
),
]

View File

@@ -8,6 +8,7 @@ from __future__ import unicode_literals
import os
import math
from decimal import Decimal
from django.core.validators import MinValueValidator
from django.db import models
@@ -19,6 +20,7 @@ from django.conf import settings
from django.contrib.staticfiles.templatetags.staticfiles import static
from InvenTree.status_codes import OrderStatus
from common.models import Currency
def rename_company_image(instance, filename):
@@ -310,7 +312,8 @@ class SupplierPart(models.Model):
# If this price-break quantity is the largest so far, use it!
if pb.quantity > pb_quantity:
pb_quantity = pb.quantity
pb_cost = pb.cost
# Convert everything to base currency
pb_cost = pb.converted_cost
if pb_found:
cost = pb_cost * quantity
@@ -369,6 +372,7 @@ class SupplierPriceBreak(models.Model):
part: Link to a SupplierPart object that this price break applies to
quantity: Quantity required for price break
cost: Cost at specified quantity
currency: Reference to the currency of this pricebreak (leave empty for base currency)
"""
part = models.ForeignKey(SupplierPart, on_delete=models.CASCADE, related_name='pricebreaks')
@@ -377,6 +381,19 @@ class SupplierPriceBreak(models.Model):
cost = models.DecimalField(max_digits=10, decimal_places=5, validators=[MinValueValidator(0)])
currency = models.ForeignKey(Currency, blank=True, null=True, on_delete=models.SET_NULL)
@property
def converted_cost(self):
""" Return the cost of this price break, converted to the base currency """
scaler = Decimal(1.0)
if self.currency:
scaler = self.currency.value
return self.cost * scaler
class Meta:
unique_together = ("part", "quantity")

View File

@@ -32,6 +32,8 @@ class CompanySerializer(InvenTreeModelSerializer):
url = serializers.CharField(source='get_absolute_url', read_only=True)
part_count = serializers.CharField(read_only=True)
image = serializers.CharField(source='get_image_url', read_only=True)
class Meta:
model = Company
fields = [

View File

@@ -27,4 +27,18 @@
]
});
$("#stock-export").click(function() {
launchModalForm("{% url 'stock-export-options' %}", {
submit_text: "Export",
success: function(response) {
var url = "{% url 'stock-export' %}";
url += "?format=" + response.format;
url += "&supplier={{ company.id }}";
location.href = url;
},
});
});
{% endblock %}

View File

@@ -55,6 +55,7 @@ InvenTree | Supplier List
{
field: 'description',
title: 'Description',
sortable: true,
},
{
field: 'website',
@@ -69,6 +70,7 @@ InvenTree | Supplier List
{
field: 'part_count',
title: 'Parts',
sortable: true,
formatter: function(value, row, index, field) {
return renderLink(value, row.url + 'parts/');
}

View File

@@ -88,7 +88,10 @@ InvenTree | {{ company.name }} - Parts
{% for pb in part.price_breaks.all %}
<tr>
<td>{{ pb.quantity }}</td>
<td>{{ pb.cost }}
<td>
{% if pb.currency %}{{ pb.currency.symbol }}{% endif %}
{{ pb.cost }}
{% if pb.currency %}{{ pb.currency.suffix }}{% endif %}
<div class='btn-group' style='float: right;'>
<button title='Edit Price Break' class='btn btn-primary pb-edit-button btn-sm' type='button' url="{% url 'price-break-edit' pb.id %}"><span class='glyphicon glyphicon-small glyphicon-edit'></span></button>
<button title='Delete Price Break' class='btn btn-danger pb-delete-button btn-sm' type='button' url="{% url 'price-break-delete' pb.id %}"><span class='glyphicon glyphicon-small glyphicon-trash'></span></button>

View File

@@ -247,6 +247,7 @@ class PurchaseOrder(Order):
if line.part:
stock = StockItem(
part=line.part.part,
supplier_part=line.part,
location=location,
quantity=quantity,
purchase_order=self)

View File

@@ -25,6 +25,27 @@ InvenTree | {{ order }}
{% if order.URL %}
<a href="{{ order.URL }}">{{ order.URL }}</a>
{% endif %}
<p>
<div class='btn-row'>
<div class='btn-group'>
<button type='button' class='btn btn-default btn-glyph' id='edit-order' title='Edit order information'>
<span class='glyphicon glyphicon-edit'></span>
</button>
<button type='button' class='btn btn-default btn-glyph' id='export-order' title='Export order to file'>
<span class='glyphicon glyphicon-download-alt'></span>
</button>
{% if order.status == OrderStatus.PENDING and order.lines.count > 0 %}
<button type='button' class='btn btn-default btn-glyph' id='place-order' title='Place order'>
<span class='glyphicon glyphicon-send'></span>
</button>
{% elif order.status == OrderStatus.PLACED %}
<button type='button' class='btn btn-default btn-glyph' id='receive-order' title='Receive items'>
<span class='glyphicon glyphicon-check'></span>
</button>
{% endif %}
</div>
</div>
</p>
</div>
</div>
</div>
@@ -65,13 +86,6 @@ InvenTree | {{ order }}
{% if order.status == OrderStatus.PENDING %}
<button type='button' class='btn btn-default' id='new-po-line'>Add Line Item</button>
{% endif %}
<button type='button' class='btn btn-primary' id='edit-order'>Edit Order</button>
{% if order.status == OrderStatus.PENDING and order.lines.count > 0 %}
<button type='button' class='btn btn-primary' id='place-order'>Place Order</button>
{% elif order.status == OrderStatus.PLACED %}
<button type='button' class='btn btn-primary' id='receive-order'>Receive Items</button>
{% endif %}
<button type='button' class='btn btn-primary' id='export-order' title='Export order to file'>Export</button>
</div>
<h4>Order Items</h4>

View File

@@ -27,7 +27,7 @@ InvenTree | Purchase Orders
$("#po-create").click(function() {
launchModalForm("{% url 'purchase-order-create' %}",
{
reload: true,
follow: true,
}
);
});

View File

@@ -12,7 +12,7 @@ from django.db.models import Sum
from rest_framework import status
from rest_framework.response import Response
from rest_framework import filters
from rest_framework import filters, serializers
from rest_framework import generics, permissions
from django.conf.urls import url, include
@@ -21,10 +21,12 @@ from django.urls import reverse
import os
from .models import Part, PartCategory, BomItem, PartStar
from .models import PartParameter, PartParameterTemplate
from .serializers import PartSerializer, BomItemSerializer
from .serializers import CategorySerializer
from .serializers import PartStarSerializer
from .serializers import PartParameterSerializer, PartParameterTemplateSerializer
from InvenTree.views import TreeSerializer
from InvenTree.helpers import str2bool
@@ -261,6 +263,53 @@ class PartStarList(generics.ListCreateAPIView):
]
class PartParameterTemplateList(generics.ListCreateAPIView):
""" API endpoint for accessing a list of PartParameterTemplate objects.
- GET: Return list of PartParameterTemplate objects
- POST: Create a new PartParameterTemplate object
"""
queryset = PartParameterTemplate.objects.all()
serializer_class = PartParameterTemplateSerializer
permission_classes = [
permissions.IsAuthenticated,
]
filter_backends = [
filters.OrderingFilter,
]
filter_fields = [
'name',
]
class PartParameterList(generics.ListCreateAPIView):
""" API endpoint for accessing a list of PartParameter objects
- GET: Return list of PartParameter objects
- POST: Create a new PartParameter object
"""
queryset = PartParameter.objects.all()
serializer_class = PartParameterSerializer
permission_classes = [
permissions.IsAuthenticated,
]
filter_backends = [
DjangoFilterBackend
]
filter_fields = [
'part',
'template',
]
class BomList(generics.ListCreateAPIView):
""" API endpoint for accessing a list of BomItem objects.
@@ -303,7 +352,7 @@ class BomList(generics.ListCreateAPIView):
filter_fields = [
'part',
'sub_part'
'sub_part',
]
@@ -318,6 +367,35 @@ class BomDetail(generics.RetrieveUpdateDestroyAPIView):
]
class BomItemValidate(generics.UpdateAPIView):
""" API endpoint for validating a BomItem """
# Very simple serializers
class BomItemValidationSerializer(serializers.Serializer):
valid = serializers.BooleanField(default=False)
queryset = BomItem.objects.all()
serializer_class = BomItemValidationSerializer
def update(self, request, *args, **kwargs):
""" Perform update request """
partial = kwargs.pop('partial', False)
valid = request.data.get('valid', False)
instance = self.get_object()
serializer = self.get_serializer(instance, data=request.data, partial=partial)
serializer.is_valid(raise_exception=True)
if type(instance) == BomItem:
instance.validate_hash(valid)
return Response(serializer.data)
cat_api_urls = [
url(r'^(?P<pk>\d+)/?', CategoryDetail.as_view(), name='api-part-category-detail'),
@@ -333,22 +411,34 @@ part_star_api_urls = [
url(r'^.*$', PartStarList.as_view(), name='api-part-star-list'),
]
part_param_api_urls = [
url(r'^template/$', PartParameterTemplateList.as_view(), name='api-part-param-template-list'),
url(r'^.*$', PartParameterList.as_view(), name='api-part-param-list'),
]
part_api_urls = [
url(r'^tree/?', PartCategoryTree.as_view(), name='api-part-tree'),
url(r'^category/', include(cat_api_urls)),
url(r'^star/', include(part_star_api_urls)),
url(r'^parameter/', include(part_param_api_urls)),
url(r'^(?P<pk>\d+)/?', PartDetail.as_view(), name='api-part-detail'),
url(r'^.*$', PartList.as_view(), name='api-part-list'),
]
bom_item_urls = [
url(r'^validate/?', BomItemValidate.as_view(), name='api-bom-item-validate'),
url(r'^.*$', BomDetail.as_view(), name='api-bom-item-detail'),
]
bom_api_urls = [
# BOM Item Detail
url(r'^(?P<pk>\d+)/?', BomDetail.as_view(), name='api-bom-detail'),
url(r'^(?P<pk>\d+)/', include(bom_item_urls)),
# Catch-all
url(r'^.*$', BomList.as_view(), name='api-bom-list'),

View File

@@ -6,7 +6,11 @@
name: Electronics
description: Electronic components
parent: null
default_location: 1 # Home
default_location: 1
level: 0
tree_id: 1
lft: 1
rght: 12
- model: part.partcategory
pk: 2
@@ -15,6 +19,10 @@
description: Resistors
parent: 1
default_location: null
level: 1
tree_id: 1
lft: 2
rght: 3
- model: part.partcategory
pk: 3
@@ -23,6 +31,10 @@
description: Capacitors
parent: 1
default_location: null
level: 1
tree_id: 1
lft: 4
rght: 5
- model: part.partcategory
pk: 4
@@ -31,6 +43,10 @@
description: Integrated Circuits
parent: 1
default_location: null
level: 1
tree_id: 1
lft: 6
rght: 11
- model: part.partcategory
pk: 5
@@ -39,6 +55,10 @@
description: Microcontrollers
parent: 4
default_location: null
level: 2
tree_id: 1
lft: 7
rght: 8
- model: part.partcategory
pk: 6
@@ -47,6 +67,10 @@
description: Communication interfaces
parent: 4
default_location: null
level: 2
tree_id: 1
lft: 9
rght: 10
- model: part.partcategory
pk: 7
@@ -54,6 +78,10 @@
name: Mechanical
description: Mechanical componenets
default_location: null
level: 0
tree_id: 2
lft: 1
rght: 4
- model: part.partcategory
pk: 8
@@ -62,3 +90,7 @@
description: Screws, bolts, etc
parent: 7
default_location: 5
level: 1
tree_id: 2
lft: 2
rght: 3

View File

@@ -0,0 +1,32 @@
# Create some PartParameter templtes
- model: part.PartParameterTemplate
pk: 1
fields:
name: Length
units: mm
- model: part.PartParameterTemplate
pk: 2
fields:
name: Width
units: mm
- model: part.PartParameterTemplate
pk: 3
fields:
name: Thickness
units: mm
# And some parameters (requires part.yaml)
- model: part.PartParameter
fields:
part: 1
template: 1
data: 4
- model: part.PartParameter
fields:
part: 2
template: 1
data: 12

View File

@@ -59,4 +59,8 @@
name: 'Bob'
description: 'Can we build it?'
assembly: true
purchaseable: false
purchaseable: false
category: 7
active: False
IPN: BOB
revision: A2

View File

@@ -8,11 +8,14 @@ from __future__ import unicode_literals
from InvenTree.forms import HelperForm
from django import forms
from django.utils.translation import ugettext as _
from .models import Part, PartCategory, PartAttachment
from .models import BomItem
from .models import PartParameterTemplate, PartParameter
from common.models import Currency
class PartImageForm(HelperForm):
""" Form for uploading a Part image """
@@ -30,7 +33,7 @@ class BomValidateForm(HelperForm):
to confirm that the BOM for this part is valid
"""
validate = forms.BooleanField(required=False, initial=False, help_text='Confirm that the BOM is correct')
validate = forms.BooleanField(required=False, initial=False, help_text=_('Confirm that the BOM is correct'))
class Meta:
model = Part
@@ -42,7 +45,7 @@ class BomValidateForm(HelperForm):
class BomUploadSelectFile(HelperForm):
""" Form for importing a BOM. Provides a file input box for upload """
bom_file = forms.FileField(label='BOM file', required=True, help_text="Select BOM file to upload")
bom_file = forms.FileField(label='BOM file', required=True, help_text=_("Select BOM file to upload"))
class Meta:
model = Part
@@ -68,12 +71,12 @@ class EditPartForm(HelperForm):
deep_copy = forms.BooleanField(required=False,
initial=True,
help_text="Perform 'deep copy' which will duplicate all BOM data for this part",
help_text=_("Perform 'deep copy' which will duplicate all BOM data for this part"),
widget=forms.HiddenInput())
confirm_creation = forms.BooleanField(required=False,
initial=False,
help_text='Confirm part creation',
help_text=_('Confirm part creation'),
widget=forms.HiddenInput())
class Meta:
@@ -160,11 +163,30 @@ class PartPriceForm(forms.Form):
quantity = forms.IntegerField(
required=True,
initial=1,
help_text='Input quantity for price calculation'
help_text=_('Input quantity for price calculation')
)
currency = forms.ChoiceField(label='Currency', help_text=_('Select currency for price calculation'))
def get_currency_choices(self):
""" Create options for Currency """
currencies = Currency.objects.all()
choices = [(None, '---------')]
for c in currencies:
choices.append((c.pk, str(c)))
return choices
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['currency'].choices = self.get_currency_choices()
class Meta:
model = Part
fields = [
'quantity'
'quantity',
'currency',
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 2.2.4 on 2019-09-05 02:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0016_auto_20190820_0257'),
]
operations = [
migrations.AddField(
model_name='bomitem',
name='checksum',
field=models.CharField(blank=True, help_text='BOM line checksum', max_length=128),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 2.2.4 on 2019-09-07 09:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0017_bomitem_checksum'),
]
operations = [
migrations.AlterField(
model_name='partparametertemplate',
name='name',
field=models.CharField(help_text='Parameter Name', max_length=100, unique=True),
),
]

View File

@@ -0,0 +1,37 @@
# Generated by Django 2.2.5 on 2019-09-08 04:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0018_auto_20190907_0941'),
]
operations = [
migrations.AddField(
model_name='partcategory',
name='level',
field=models.PositiveIntegerField(default=0, editable=False),
preserve_default=False,
),
migrations.AddField(
model_name='partcategory',
name='lft',
field=models.PositiveIntegerField(default=0, editable=False),
preserve_default=False,
),
migrations.AddField(
model_name='partcategory',
name='rght',
field=models.PositiveIntegerField(default=0, editable=False),
preserve_default=False,
),
migrations.AddField(
model_name='partcategory',
name='tree_id',
field=models.PositiveIntegerField(db_index=True, default=0, editable=False),
preserve_default=False,
),
]

View File

@@ -0,0 +1,21 @@
# Generated by Django 2.2.5 on 2019-09-08 04:04
from django.db import migrations
from part import models
def update_tree(apps, schema_editor):
# Update the PartCategory MPTT model
models.PartCategory.objects.rebuild()
class Migration(migrations.Migration):
dependencies = [
('part', '0019_auto_20190908_0404'),
]
operations = [
migrations.RunPython(update_tree)
]

View File

@@ -0,0 +1,30 @@
# Generated by Django 2.2.5 on 2019-09-08 09:16
from django.db import migrations
import django.db.models.deletion
import mptt.fields
class Migration(migrations.Migration):
dependencies = [
('part', '0020_auto_20190908_0404'),
]
operations = [
migrations.AlterField(
model_name='part',
name='category',
field=mptt.fields.TreeForeignKey(blank=True, help_text='Part category', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='parts', to='part.PartCategory'),
),
migrations.AlterField(
model_name='part',
name='default_location',
field=mptt.fields.TreeForeignKey(blank=True, help_text='Where is this item normally stored?', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_parts', to='stock.StockLocation'),
),
migrations.AlterField(
model_name='partcategory',
name='default_location',
field=mptt.fields.TreeForeignKey(blank=True, help_text='Default location for parts in this category', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_categories', to='stock.StockLocation'),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 2.2.5 on 2019-09-08 09:18
from django.db import migrations
import django.db.models.deletion
import mptt.fields
class Migration(migrations.Migration):
dependencies = [
('part', '0021_auto_20190908_0916'),
]
operations = [
migrations.AlterField(
model_name='partcategory',
name='parent',
field=mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='children', to='part.PartCategory'),
),
]

View File

@@ -25,6 +25,8 @@ from django.contrib.auth.models import User
from django.db.models.signals import pre_delete
from django.dispatch import receiver
from mptt.models import TreeForeignKey
from datetime import datetime
from fuzzywuzzy import fuzz
import hashlib
@@ -48,7 +50,7 @@ class PartCategory(InvenTreeTree):
default_keywords: Default keywords for parts created in this category
"""
default_location = models.ForeignKey(
default_location = TreeForeignKey(
'stock.StockLocation', related_name="default_categories",
null=True, blank=True,
on_delete=models.SET_NULL,
@@ -64,21 +66,31 @@ class PartCategory(InvenTreeTree):
verbose_name = "Part Category"
verbose_name_plural = "Part Categories"
def get_parts(self, cascade=True):
""" Return a queryset for all parts under this category.
args:
cascade - If True, also look under subcategories (default = True)
"""
if cascade:
""" Select any parts which exist in this category or any child categories """
query = Part.objects.filter(category__in=self.getUniqueChildren(include_self=True))
else:
query = Part.objects.filter(category=self.pk)
return query
@property
def item_count(self):
return self.partcount()
def partcount(self, cascade=True, active=True):
def partcount(self, cascade=True, active=False):
""" Return the total part count under this category
(including children of child categories)
"""
cats = [self.id]
if cascade:
cats += [cat for cat in self.getUniqueChildren()]
query = Part.objects.filter(category__in=cats)
query = self.get_parts(cascade=cascade)
if active:
query = query.filter(active=True)
@@ -88,7 +100,7 @@ class PartCategory(InvenTreeTree):
@property
def has_parts(self):
""" True if there are any parts in this category """
return self.parts.count() > 0
return self.partcount() > 0
@receiver(pre_delete, sender=PartCategory, dispatch_uid='partcategory_delete_log')
@@ -253,17 +265,9 @@ class Part(models.Model):
def set_category(self, category):
if not type(category) == PartCategory:
raise ValidationError({
'category': _('Invalid object supplied to part.set_category')
})
try:
# Already in this category!
if category == self.category:
return
except PartCategory.DoesNotExist:
pass
# Ignore if the category is already the same
if self.category == category:
return
self.category = category
self.save()
@@ -340,10 +344,10 @@ class Part(models.Model):
keywords = models.CharField(max_length=250, blank=True, help_text='Part keywords to improve visibility in search results')
category = models.ForeignKey(PartCategory, related_name='parts',
null=True, blank=True,
on_delete=models.DO_NOTHING,
help_text='Part category')
category = TreeForeignKey(PartCategory, related_name='parts',
null=True, blank=True,
on_delete=models.DO_NOTHING,
help_text='Part category')
IPN = models.CharField(max_length=100, blank=True, help_text='Internal Part Number')
@@ -353,10 +357,10 @@ class Part(models.Model):
image = models.ImageField(upload_to=rename_part_image, max_length=255, null=True, blank=True)
default_location = models.ForeignKey('stock.StockLocation', on_delete=models.SET_NULL,
blank=True, null=True,
help_text='Where is this item normally stored?',
related_name='default_parts')
default_location = TreeForeignKey('stock.StockLocation', on_delete=models.SET_NULL,
blank=True, null=True,
help_text='Where is this item normally stored?',
related_name='default_parts')
def get_default_location(self):
""" Get the default location for a Part (may be None).
@@ -370,13 +374,11 @@ class Part(models.Model):
return self.default_location
elif self.category:
# Traverse up the category tree until we find a default location
cat = self.category
cats = self.category.get_ancestors(ascending=True, include_self=True)
while cat:
for cat in cats:
if cat.default_location:
return cat.default_location
else:
cat = cat.parent
# Default case - no default category found
return None
@@ -631,24 +633,15 @@ class Part(models.Model):
""" Return a checksum hash for the BOM for this part.
Used to determine if the BOM has changed (and needs to be signed off!)
For hash is calculated from the following fields of each BOM item:
The hash is calculated by hashing each line item in the BOM.
- Part.full_name (if the part name changes, the BOM checksum is invalidated)
- Quantity
- Reference field
- Note field
returns a string representation of a hash object which can be compared with a stored value
"""
hash = hashlib.md5(str(self.id).encode())
for item in self.bom_items.all().prefetch_related('sub_part'):
hash.update(str(item.sub_part.id).encode())
hash.update(str(item.sub_part.full_name).encode())
hash.update(str(item.quantity).encode())
hash.update(str(item.note).encode())
hash.update(str(item.reference).encode())
hash.update(str(item.get_item_hash()).encode())
return str(hash.digest())
@@ -667,6 +660,10 @@ class Part(models.Model):
- Saves the current date and the checking user
"""
# Validate each line item too
for item in self.bom_items.all():
item.validate_hash()
self.bom_checksum = self.get_bom_hash()
self.bom_checked_by = user
self.bom_checked_date = datetime.now().date()
@@ -1060,7 +1057,7 @@ class PartParameterTemplate(models.Model):
super().validate_unique(exclude)
try:
others = PartParameterTemplate.objects.exclude(id=self.id).filter(name__iexact=self.name)
others = PartParameterTemplate.objects.filter(name__iexact=self.name).exclude(pk=self.pk)
if others.exists():
msg = _("Parameter template name must be unique")
@@ -1068,12 +1065,7 @@ class PartParameterTemplate(models.Model):
except PartParameterTemplate.DoesNotExist:
pass
@property
def instance_count(self):
""" Return the number of instances of this Parameter Template """
return self.instances.count()
name = models.CharField(max_length=100, help_text='Parameter Name')
name = models.CharField(max_length=100, help_text='Parameter Name', unique=True)
units = models.CharField(max_length=25, help_text='Parameter Units', blank=True)
@@ -1091,7 +1083,7 @@ class PartParameter(models.Model):
def __str__(self):
# String representation of a PartParameter (used in the admin interface)
return "{part} : {param} = {data}{units}".format(
part=str(self.part),
part=str(self.part.full_name),
param=str(self.template.name),
data=str(self.data),
units=str(self.template.units)
@@ -1101,8 +1093,7 @@ class PartParameter(models.Model):
# Prevent multiple instances of a parameter for a single part
unique_together = ('part', 'template')
part = models.ForeignKey(Part, on_delete=models.CASCADE,
related_name='parameters', help_text='Parent Part')
part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='parameters', help_text='Parent Part')
template = models.ForeignKey(PartParameterTemplate, on_delete=models.CASCADE, related_name='instances', help_text='Parameter Template')
@@ -1121,6 +1112,7 @@ class BomItem(models.Model):
reference: BOM reference field (e.g. part designators)
overage: Estimated losses for a Build. Can be expressed as absolute value (e.g. '7') or a percentage (e.g. '2%')
note: Note field for this BOM item
checksum: Validation checksum for the particular BOM line item
"""
def get_absolute_url(self):
@@ -1154,6 +1146,56 @@ class BomItem(models.Model):
# Note attached to this BOM line item
note = models.CharField(max_length=500, blank=True, help_text='BOM item notes')
checksum = models.CharField(max_length=128, blank=True, help_text='BOM line checksum')
def get_item_hash(self):
""" Calculate the checksum hash of this BOM line item:
The hash is calculated from the following fields:
- Part.full_name (if the part name changes, the BOM checksum is invalidated)
- Quantity
- Reference field
- Note field
"""
# Seed the hash with the ID of this BOM item
hash = hashlib.md5(str(self.id).encode())
# Update the hash based on line information
hash.update(str(self.sub_part.id).encode())
hash.update(str(self.sub_part.full_name).encode())
hash.update(str(self.quantity).encode())
hash.update(str(self.note).encode())
hash.update(str(self.reference).encode())
return str(hash.digest())
def validate_hash(self, valid=True):
""" Mark this item as 'valid' (store the checksum hash).
Args:
valid: If true, validate the hash, otherwise invalidate it (default = True)
"""
if valid:
self.checksum = str(self.get_item_hash())
else:
self.checksum = ''
self.save()
@property
def is_line_valid(self):
""" Check if this line item has been validated by the user """
# Ensure an empty checksum returns False
if len(self.checksum) == 0:
return False
return self.get_item_hash() == self.checksum
def clean(self):
""" Check validity of the BomItem model.

View File

@@ -8,6 +8,7 @@ from .models import Part, PartStar
from .models import PartCategory
from .models import BomItem
from .models import PartParameter, PartParameterTemplate
from InvenTree.serializers import InvenTreeModelSerializer
@@ -131,6 +132,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
sub_part_detail = PartBriefSerializer(source='sub_part', many=False, read_only=True)
price_range = serializers.CharField(read_only=True)
validated = serializers.BooleanField(read_only=True, source='is_line_valid')
def __init__(self, *args, **kwargs):
# part_detail and sub_part_detail serializers are only included if requested.
@@ -171,4 +173,30 @@ class BomItemSerializer(InvenTreeModelSerializer):
'price_range',
'overage',
'note',
'validated',
]
class PartParameterSerializer(InvenTreeModelSerializer):
""" JSON serializers for the PartParameter model """
class Meta:
model = PartParameter
fields = [
'pk',
'part',
'template',
'data'
]
class PartParameterTemplateSerializer(InvenTreeModelSerializer):
""" JSON serializer for the PartParameterTemplate model """
class Meta:
model = PartParameterTemplate
fields = [
'pk',
'name',
'units',
]

View File

@@ -31,10 +31,10 @@
<td>{{ attachment.comment }}</td>
<td>
<div class='btn-group' style='float: right;'>
<button type='button' class='btn btn-default btn-glyph' url="{% url 'part-attachment-edit' attachment.id %}" data-toggle='tooltip' title='Edit attachment ({{ attachment.basename }})'>
<button type='button' class='btn btn-default btn-glyph attachment-edit-button' url="{% url 'part-attachment-edit' attachment.id %}" data-toggle='tooltip' title='Edit attachment ({{ attachment.basename }})'>
<span class='glyphicon glyphicon-edit'/>
</button>
<button type='button' class='btn btn-default btn-glyph' url="{% url 'part-attachment-delete' attachment.id %}" data-toggle='tooltip' title='Delete attachment ({{ attachment.basename }})'>
<button type='button' class='btn btn-default btn-glyph attachment-delete-button' url="{% url 'part-attachment-delete' attachment.id %}" data-toggle='tooltip' title='Delete attachment ({{ attachment.basename }})'>
<span class='glyphicon glyphicon-trash'/>
</button>
</div>

View File

@@ -2,4 +2,9 @@
{% block pre_form_content %}
Confirm that the Bill of Materials (BOM) is valid for:<br><i>{{ part.full_name }}</i>
<div class='alert alert-warning alert-block'>
This will validate each line in the BOM.
</div>
{% endblock %}

View File

@@ -130,6 +130,7 @@
<td><i>Part can be purchased from external suppliers</i></td>
{% endif %}
</tr>
{% if 0 %}
<tr>
<td><b>Sellable</b></td>
<td>{% include "slide.html" with state=part.salable field='salable' %}</td>
@@ -139,6 +140,7 @@
<td><i>Part cannot be sold to customers</i></td>
{% endif %}
</tr>
{% endif %}
</table>
</div>
</div>

View File

@@ -24,14 +24,14 @@ Pricing information for:<br>
{% if min_total_buy_price %}
<tr>
<td><b>Unit Cost</b></td>
<td>Min: {{ min_unit_buy_price }}</td>
<td>Max: {{ max_unit_buy_price }}</td>
<td>Min: {% include "price.html" with price=min_unit_buy_price %}</td>
<td>Max: {% include "price.html" with price=max_unit_buy_price %}</td>
</tr>
{% if quantity > 1 %}
<tr>
<td><b>Total Cost</b></td>
<td>Min: {{ min_total_buy_price }}</td>
<td>Max: {{ max_total_buy_price }}</td>
<td>Min: {% include "price.html" with price=min_total_buy_price %}</td>
<td>Max: {% include "price.html" with price=max_total_buy_price %}</td>
</tr>
{% endif %}
{% else %}
@@ -50,14 +50,14 @@ Pricing information for:<br>
{% if min_total_bom_price %}
<tr>
<td><b>Unit Cost</b></td>
<td>Min: {{ min_unit_bom_price }}</td>
<td>Max: {{ max_unit_bom_price }}</td>
<td>Min: {% include "price.html" with price=min_unit_bom_price %}</td>
<td>Max: {% include "price.html" with price=max_unit_bom_price %}</td>
</tr>
{% if quantity > 1 %}
<tr>
<td><b>Total Cost</b></td>
<td>Min: {{ min_total_bom_price }}</td>
<td>Max: {{ max_total_bom_price }}</td>
<td>Min: {% include "price.html" with price=min_total_bom_price %}</td>
<td>Max: {% include "price.html" with price=max_total_bom_price %}</td>
</tr>
{% endif %}
{% if part.has_complete_bom_pricing == False %}

View File

@@ -47,6 +47,21 @@
url: "{% url 'api-stock-list' %}",
});
$("#stock-export").click(function() {
launchModalForm("{% url 'stock-export-options' %}", {
submit_text: "Export",
success: function(response) {
var url = "{% url 'stock-export' %}";
url += "?format=" + response.format;
url += "&cascade=" + response.cascade;
url += "&part={{ part.id }}";
location.href = url;
},
});
});
$('#item-create').click(function () {
launchModalForm("{% url 'stock-item-create' %}", {
reload: true,

View File

@@ -120,7 +120,7 @@ class PartAPITest(APITestCase):
def test_get_bom_detail(self):
# Get the detail for a single BomItem
url = reverse('api-bom-detail', kwargs={'pk': 3})
url = reverse('api-bom-item-detail', kwargs={'pk': 3})
response = self.client.get(url, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['quantity'], 25)

View File

@@ -19,7 +19,7 @@ class BomItemTest(TestCase):
def test_str(self):
b = BomItem.objects.get(id=1)
self.assertEqual(str(b), '10 x M2x4 LPHS to make Bob')
self.assertEqual(str(b), '10 x M2x4 LPHS to make BOB | Bob | A2')
def test_has_bom(self):
self.assertFalse(self.orphan.has_bom)

View File

@@ -48,7 +48,7 @@ class CategoryTest(TestCase):
def test_unique_childs(self):
""" Test the 'unique_children' functionality """
childs = self.electronics.getUniqueChildren()
childs = [item.pk for item in self.electronics.getUniqueChildren()]
self.assertIn(self.transceivers.id, childs)
self.assertIn(self.ic.id, childs)
@@ -58,7 +58,7 @@ class CategoryTest(TestCase):
def test_unique_parents(self):
""" Test the 'unique_parents' functionality """
parents = self.transceivers.getUniqueParents()
parents = [item.pk for item in self.transceivers.getUniqueParents()]
self.assertIn(self.electronics.id, parents)
self.assertIn(self.ic.id, parents)
@@ -67,8 +67,8 @@ class CategoryTest(TestCase):
def test_path_string(self):
""" Test that the category path string works correctly """
self.assertEqual(str(self.resistors), 'Electronics/Resistors')
self.assertEqual(str(self.transceivers), 'Electronics/IC/Transceivers')
self.assertEqual(str(self.resistors), 'Electronics/Resistors - Resistors')
self.assertEqual(str(self.transceivers.pathstring), 'Electronics/IC/Transceivers')
def test_url(self):
""" Test that the PartCategory URL works """
@@ -87,6 +87,12 @@ class CategoryTest(TestCase):
self.assertEqual(self.electronics.partcount(), 3)
self.assertEqual(self.mechanical.partcount(), 4)
self.assertEqual(self.mechanical.partcount(active=True), 3)
self.assertEqual(self.mechanical.partcount(False), 2)
self.assertEqual(self.electronics.item_count, self.electronics.partcount())
def test_delete(self):
""" Test that category deletion moves the children properly """
@@ -111,11 +117,11 @@ class CategoryTest(TestCase):
def test_default_locations(self):
""" Test traversal for default locations """
self.assertEqual(str(self.fasteners.default_location), 'Office/Drawer_1')
self.assertEqual(str(self.fasteners.default_location), 'Office/Drawer_1 - In my desk')
# Test that parts in this location return the same default location, too
for p in self.fasteners.children.all():
self.assert_equal(p.get_default_location(), 'Office/Drawer_1')
self.assert_equal(p.get_default_location().pathstring, 'Office/Drawer_1')
# Any part under electronics should default to 'Home'
R1 = Part.objects.get(name='R_2K2_0805')

View File

@@ -0,0 +1,42 @@
# Tests for Part Parameters
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.test import TestCase
import django.core.exceptions as django_exceptions
from .models import PartParameter, PartParameterTemplate
class TestParams(TestCase):
fixtures = [
'location',
'category',
'part',
'params'
]
def test_str(self):
t1 = PartParameterTemplate.objects.get(pk=1)
self.assertEquals(str(t1), 'Length (mm)')
p1 = PartParameter.objects.get(pk=1)
self.assertEqual(str(p1), "M2x4 LPHS : Length = 4mm")
def test_validate(self):
n = PartParameterTemplate.objects.all().count()
t1 = PartParameterTemplate(name='abcde', units='dd')
t1.save()
self.assertEqual(n + 1, PartParameterTemplate.objects.all().count())
# Test that the case-insensitive name throws a ValidationError
with self.assertRaises(django_exceptions.ValidationError):
t3 = PartParameterTemplate(name='aBcde', units='dd')
t3.full_clean()
t3.save()

View File

@@ -1,9 +1,14 @@
# Tests for the Part model
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.test import TestCase
import os
from .models import Part
from .models import rename_part_image
from .models import rename_part_image, match_part_names
from .templatetags import inventree_extras
@@ -39,12 +44,16 @@ class PartTest(TestCase):
self.C1 = Part.objects.get(name='C_22N_0805')
def test_str(self):
p = Part.objects.get(pk=100)
self.assertEqual(str(p), "BOB | Bob | A2 - Can we build it?")
def test_metadata(self):
self.assertEqual(self.R1.name, 'R_2K2_0805')
self.assertEqual(self.R1.get_absolute_url(), '/part/3/')
def test_category(self):
self.assertEqual(str(self.C1.category), 'Electronics/Capacitors')
self.assertEqual(str(self.C1.category), 'Electronics/Capacitors - Capacitors')
orphan = Part.objects.get(name='Orphan')
self.assertIsNone(orphan.category)
@@ -70,5 +79,10 @@ class PartTest(TestCase):
self.assertIn(self.R1.name, barcode)
def test_copy(self):
self.R2.deepCopy(self.R1, image=True, bom=True)
def test_match_names(self):
matches = match_part_names('M2x5 LPHS')
self.assertTrue(len(matches) > 0)

View File

@@ -142,8 +142,8 @@ class PartAttachmentTests(PartViewTestCase):
def test_invalid_create(self):
""" test creation of an attachment for an invalid part """
with self.assertRaises(Part.DoesNotExist):
self.client.get(reverse('part-attachment-create'), {'part': 999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
# TODO
pass
def test_edit(self):
""" test editing an attachment """
@@ -167,7 +167,7 @@ class PartQRTest(PartViewTestCase):
data = str(response.content)
self.assertIn('Part QR Code', data)
self.assertIn('<img src=', data)
self.assertIn('<img class=', data)
def test_invalid_part(self):
response = self.client.get(reverse('part-qr', args=(9999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')

View File

@@ -21,6 +21,8 @@ part_attachment_urls = [
part_parameter_urls = [
url('^template/new/', views.PartParameterTemplateCreate.as_view(), name='part-param-template-create'),
url('^template/(?P<pk>\d+)/edit/', views.PartParameterTemplateEdit.as_view(), name='part-param-template-edit'),
url('^template/(?P<pk>\d+)/delete/', views.PartParameterTemplateDelete.as_view(), name='part-param-template-edit'),
url('^new/', views.PartParameterCreate.as_view(), name='part-param-create'),
url('^(?P<pk>\d+)/edit/', views.PartParameterEdit.as_view(), name='part-param-edit'),

View File

@@ -17,12 +17,14 @@ from django.forms import HiddenInput, CheckboxInput
import tablib
from fuzzywuzzy import fuzz
from decimal import Decimal
from .models import PartCategory, Part, PartAttachment
from .models import PartParameterTemplate, PartParameter
from .models import BomItem
from .models import match_part_names
from common.models import Currency
from company.models import SupplierPart
from . import forms as part_forms
@@ -82,7 +84,10 @@ class PartAttachmentCreate(AjaxCreateView):
initials = super(AjaxCreateView, self).get_initial()
# TODO - If the proper part was not sent, return an error message
initials['part'] = Part.objects.get(id=self.request.GET.get('part'))
try:
initials['part'] = Part.objects.get(id=self.request.GET.get('part', None))
except (ValueError, Part.DoesNotExist):
pass
return initials
@@ -1325,7 +1330,7 @@ class PartPricing(AjaxView):
except Part.DoesNotExist:
return None
def get_pricing(self, quantity=1):
def get_pricing(self, quantity=1, currency=None):
try:
quantity = int(quantity)
@@ -1335,11 +1340,25 @@ class PartPricing(AjaxView):
if quantity < 1:
quantity = 1
if currency is None:
# No currency selected? Try to select a default one
try:
currency = Currency.objects.get(base=1)
except Currency.DoesNotExist:
currency = None
# Currency scaler
scaler = Decimal(1.0)
if currency is not None:
scaler = Decimal(currency.value)
part = self.get_part()
ctx = {
'part': part,
'quantity': quantity
'quantity': quantity,
'currency': currency,
}
if part is None:
@@ -1352,6 +1371,12 @@ class PartPricing(AjaxView):
if buy_price is not None:
min_buy_price, max_buy_price = buy_price
min_buy_price /= scaler
max_buy_price /= scaler
min_buy_price = round(min_buy_price, 3)
max_buy_price = round(max_buy_price, 3)
if min_buy_price:
ctx['min_total_buy_price'] = min_buy_price
ctx['min_unit_buy_price'] = min_buy_price / quantity
@@ -1368,6 +1393,12 @@ class PartPricing(AjaxView):
if bom_price is not None:
min_bom_price, max_bom_price = bom_price
min_bom_price /= scaler
max_bom_price /= scaler
min_bom_price = round(min_bom_price, 3)
max_bom_price = round(max_bom_price, 3)
if min_bom_price:
ctx['min_total_bom_price'] = min_bom_price
ctx['min_unit_bom_price'] = min_bom_price / quantity
@@ -1384,17 +1415,27 @@ class PartPricing(AjaxView):
def post(self, request, *args, **kwargs):
currency = None
try:
quantity = int(self.request.POST.get('quantity', 1))
except ValueError:
quantity = 1
try:
currency_id = int(self.request.POST.get('currency', None))
if currency_id:
currency = Currency.objects.get(pk=currency_id)
except (ValueError, Currency.DoesNotExist):
currency = None
# Always mark the form as 'invalid' (the user may wish to keep getting pricing data)
data = {
'form_valid': False,
}
return self.renderJsonResponse(request, self.form_class(), data=data, context=self.get_pricing(quantity))
return self.renderJsonResponse(request, self.form_class(), data=data, context=self.get_pricing(quantity, currency))
class PartParameterTemplateCreate(AjaxCreateView):
@@ -1405,6 +1446,21 @@ class PartParameterTemplateCreate(AjaxCreateView):
ajax_form_title = 'Create Part Parameter Template'
class PartParameterTemplateEdit(AjaxUpdateView):
""" View for editing a PartParameterTemplate """
model = PartParameterTemplate
form_class = part_forms.EditPartParameterTemplateForm
ajax_form_title = 'Edit Part Parameter Template'
class PartParameterTemplateDelete(AjaxDeleteView):
""" View for deleting an existing PartParameterTemplate """
model = PartParameterTemplate
ajax_form_title = "Delete Part Parameter Template"
class PartParameterCreate(AjaxCreateView):
""" View for creating a new PartParameter """

View File

@@ -5,6 +5,10 @@
fields:
name: 'Home'
description: 'My house'
level: 0
tree_id: 1
lft: 1
rght: 6
- model: stock.stocklocation
pk: 2
@@ -12,6 +16,10 @@
name: 'Bathroom'
description: 'Where I keep my bath'
parent: 1
level: 1
tree_id: 1
lft: 2
rght: 3
- model: stock.stocklocation
pk: 3
@@ -19,12 +27,20 @@
name: 'Dining Room'
description: 'A table lives here'
parent: 1
level: 0
tree_id: 1
lft: 4
rght: 5
- model: stock.stocklocation
pk: 4
fields:
name: 'Office'
description: 'Place of work'
level: 0
tree_id: 2
lft: 1
rght: 8
- model: stock.stocklocation
pk: 5
@@ -32,6 +48,10 @@
name: 'Drawer_1'
description: 'In my desk'
parent: 4
level: 0
tree_id: 2
lft: 2
rght: 3
- model: stock.stocklocation
pk: 6
@@ -39,10 +59,18 @@
name: 'Drawer_2'
description: 'Also in my desk'
parent: 4
level: 0
tree_id: 2
lft: 4
rght: 5
- model: stock.stocklocation
pk: 7
fields:
name: 'Drawer_3'
description: 'Again, in my desk'
parent: 4
parent: 4
level: 0
tree_id: 2
lft: 6
rght: 7

View File

@@ -9,6 +9,7 @@ from django import forms
from django.forms.utils import ErrorDict
from django.utils.translation import ugettext as _
from InvenTree.helpers import GetExportFormats
from InvenTree.forms import HelperForm
from .models import StockLocation, StockItem, StockItemTracking
@@ -96,6 +97,33 @@ class SerializeStockForm(forms.ModelForm):
]
class ExportOptionsForm(HelperForm):
""" Form for selecting stock export options """
file_format = forms.ChoiceField(label=_('File Format'), help_text=_('Select output file format'))
include_sublocations = forms.BooleanField(required=False, initial=True, help_text=_("Include stock items in sub locations"))
class Meta:
model = StockLocation
fields = [
'file_format',
'include_sublocations',
]
def get_format_choices(self):
""" File format choices """
choices = [(x, x.upper()) for x in GetExportFormats()]
return choices
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['file_format'].choices = self.get_format_choices()
class AdjustStockForm(forms.ModelForm):
""" Form for performing simple stock adjustments.
@@ -117,10 +145,12 @@ class AdjustStockForm(forms.ModelForm):
return choices
destination = forms.ChoiceField(label='Destination', required=True, help_text='Destination stock location')
destination = forms.ChoiceField(label='Destination', required=True, help_text=_('Destination stock location'))
note = forms.CharField(label='Notes', required=True, help_text='Add note (required)')
# transaction = forms.BooleanField(required=False, initial=False, label='Create Transaction', help_text='Create a stock transaction for these parts')
confirm = forms.BooleanField(required=False, initial=False, label='Confirm stock adjustment', help_text='Confirm movement of stock items')
confirm = forms.BooleanField(required=False, initial=False, label='Confirm stock adjustment', help_text=_('Confirm movement of stock items'))
set_loc = forms.BooleanField(required=False, initial=False, label='Set Default Location', help_text=_('Set the destination as the default location for selected parts'))
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

View File

@@ -0,0 +1,37 @@
# Generated by Django 2.2.5 on 2019-09-08 04:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stock', '0010_stockitem_build'),
]
operations = [
migrations.AddField(
model_name='stocklocation',
name='level',
field=models.PositiveIntegerField(default=0, editable=False),
preserve_default=False,
),
migrations.AddField(
model_name='stocklocation',
name='lft',
field=models.PositiveIntegerField(default=0, editable=False),
preserve_default=False,
),
migrations.AddField(
model_name='stocklocation',
name='rght',
field=models.PositiveIntegerField(default=0, editable=False),
preserve_default=False,
),
migrations.AddField(
model_name='stocklocation',
name='tree_id',
field=models.PositiveIntegerField(db_index=True, default=0, editable=False),
preserve_default=False,
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 2.2.5 on 2019-09-08 04:05
from django.db import migrations
from stock import models
def update_tree(apps, schema_editor):
# Update the StockLocation MPTT model
models.StockLocation.objects.rebuild()
class Migration(migrations.Migration):
dependencies = [
('stock', '0011_auto_20190908_0404'),
]
operations = [
migrations.RunPython(update_tree)
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 2.2.5 on 2019-09-08 09:16
from django.db import migrations
import django.db.models.deletion
import mptt.fields
class Migration(migrations.Migration):
dependencies = [
('stock', '0012_auto_20190908_0405'),
]
operations = [
migrations.AlterField(
model_name='stockitem',
name='location',
field=mptt.fields.TreeForeignKey(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

@@ -0,0 +1,20 @@
# Generated by Django 2.2.5 on 2019-09-08 09:18
from django.db import migrations
import django.db.models.deletion
import mptt.fields
class Migration(migrations.Migration):
dependencies = [
('stock', '0013_auto_20190908_0916'),
]
operations = [
migrations.AlterField(
model_name='stocklocation',
name='parent',
field=mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='children', to='stock.StockLocation'),
),
]

View File

@@ -16,6 +16,8 @@ from django.contrib.auth.models import User
from django.db.models.signals import pre_delete
from django.dispatch import receiver
from mptt.models import TreeForeignKey
from datetime import datetime
from InvenTree import helpers
@@ -34,9 +36,6 @@ class StockLocation(InvenTreeTree):
def get_absolute_url(self):
return reverse('stock-location-detail', kwargs={'pk': self.id})
def has_items(self):
return self.stock_items.count() > 0
def format_barcode(self):
""" Return a JSON string for formatting a barcode for this StockLocation object """
@@ -49,16 +48,33 @@ class StockLocation(InvenTreeTree):
}
)
def get_stock_items(self, cascade=True):
""" Return a queryset for all stock items under this category.
Args:
cascade: If True, also look under sublocations (default = True)
"""
if cascade:
query = StockItem.objects.filter(location__in=self.getUniqueChildren(include_self=True))
else:
query = StockItem.objects.filter(location=self.pk)
return query
def stock_item_count(self, cascade=True):
""" Return the number of StockItem objects which live in or under this category
"""
if cascade:
query = StockItem.objects.filter(location__in=self.getUniqueChildren())
else:
query = StockItem.objects.filter(location=self)
return self.get_stock_items(cascade).count()
return query.count()
def has_items(self, cascade=True):
""" Return True if there are StockItems existing in this category.
Args:
cascade: If True, also search an sublocations (default = True)
"""
return self.stock_item_count(cascade) > 0
@property
def item_count(self):
@@ -172,12 +188,12 @@ class StockItem(models.Model):
if self.part.variant_of is not None:
if StockItem.objects.filter(part__variant_of=self.part.variant_of, serial=self.serial).exclude(id=self.id).exists():
raise ValidationError({
'serial': _('A part with this serial number already exists for template part {part}'.format(part=self.part.variant_of))
'serial': _('A stock item with this serial number already exists for template part {part}'.format(part=self.part.variant_of))
})
else:
if StockItem.objects.filter(serial=self.serial).exclude(id=self.id).exists():
if StockItem.objects.filter(part=self.part, serial=self.serial).exclude(id=self.id).exists():
raise ValidationError({
'serial': _('A part with this serial number already exists')
'serial': _('A stock item with this serial number already exists')
})
except Part.DoesNotExist:
pass
@@ -277,9 +293,9 @@ class StockItem(models.Model):
supplier_part = models.ForeignKey('company.SupplierPart', blank=True, null=True, on_delete=models.SET_NULL,
help_text='Select a matching supplier part for this stock item')
location = models.ForeignKey(StockLocation, on_delete=models.DO_NOTHING,
related_name='stock_items', blank=True, null=True,
help_text='Where is this stock item located?')
location = TreeForeignKey(StockLocation, on_delete=models.DO_NOTHING,
related_name='stock_items', blank=True, null=True,
help_text='Where is this stock item located?')
belongs_to = models.ForeignKey('self', on_delete=models.DO_NOTHING,
related_name='owned_parts', blank=True, null=True,

View File

@@ -49,6 +49,10 @@
<div class='alert alert-block alert-info'>
This stock item is serialized - it has a unique serial number and the quantity cannot be adjusted.
</div>
{% elif item.delete_on_deplete %}
<div class='alert alert-block alert-warning'>
This stock item will be automatically deleted when all stock is depleted.
</div>
{% endif %}
</div>
@@ -221,6 +225,7 @@
item: {{ item.id }},
},
reload: true,
follow: true,
}
);
}

View File

@@ -67,6 +67,24 @@
sessionStorage.removeItem('inventree-show-part-locations');
});
$("#stock-export").click(function() {
launchModalForm("{% url 'stock-export-options' %}", {
submit_text: "Export",
success: function(response) {
var url = "{% url 'stock-export' %}";
url += "?format=" + response.format;
url += "&cascade=" + response.cascade;
{% if location %}
url += "&location={{ location.id }}";
{% endif %}
location.href = url;
}
});
});
$('#location-create').click(function () {
launchModalForm("{% url 'stock-location-create' %}",
{

View File

@@ -7,8 +7,8 @@ Sub-Locations<span class='badge'>{{ children|length }}</span>
{% block collapse_content %}
<ul class="list-group">
{% for child in children %}
<li class="list-group-item"><a href="{% url 'stock-location-detail' child.id %}">{{ child.name }}</a> - <i>{{ child.description }}</i></li>
<span class='badge'>{{ child.partcount }}</span>
<li class="list-group-item"><a href="{% url 'stock-location-detail' child.id %}">{{ child.name }}</a> - <i>{{ child.description }}</i>
<span class='badge'>{{ child.item_count }}</span>
</li>
{% endfor %}
</ul>

View File

@@ -67,15 +67,18 @@ class StockTest(TestCase):
# Move one of the drawers
self.drawer3.parent = self.home
self.drawer3.save()
self.assertNotEqual(self.drawer3.parent, self.office)
self.assertEqual(self.drawer3.pathstring, 'Home/Drawer_3')
def test_children(self):
self.assertTrue(self.office.has_children)
self.assertFalse(self.drawer2.has_children)
childs = self.office.getUniqueChildren()
childs = [item.pk for item in self.office.getUniqueChildren()]
self.assertIn(self.drawer1.id, childs)
self.assertIn(self.drawer2.id, childs)

View File

@@ -51,6 +51,9 @@ stock_urls = [
url(r'^adjust/?', views.StockAdjust.as_view(), name='stock-adjust'),
url(r'^export-options/?', views.StockExportOptions.as_view(), name='stock-export-options'),
url(r'^export/?', views.StockExport.as_view(), name='stock-export'),
# Individual stock items
url(r'^item/(?P<pk>\d+)/', include(stock_item_detail_urls)),

View File

@@ -10,6 +10,7 @@ from django.views.generic.edit import FormMixin
from django.views.generic import DetailView, ListView
from django.forms.models import model_to_dict
from django.forms import HiddenInput
from django.urls import reverse
from django.utils.translation import ugettext as _
@@ -17,10 +18,14 @@ from InvenTree.views import AjaxView
from InvenTree.views import AjaxUpdateView, AjaxDeleteView, AjaxCreateView
from InvenTree.views import QRCodeView
from InvenTree.helpers import str2bool
from InvenTree.status_codes import StockStatus
from InvenTree.helpers import str2bool, DownloadFile, GetExportFormats
from InvenTree.helpers import ExtractSerialNumbers
from datetime import datetime
import tablib
from company.models import Company
from part.models import Part
from .models import StockItem, StockLocation, StockItemTracking
@@ -30,6 +35,7 @@ from .forms import EditStockItemForm
from .forms import AdjustStockForm
from .forms import TrackingEntryForm
from .forms import SerializeStockForm
from .forms import ExportOptionsForm
class StockIndex(ListView):
@@ -118,6 +124,181 @@ class StockLocationQRCode(QRCodeView):
return None
class StockExportOptions(AjaxView):
""" Form for selecting StockExport options """
model = StockLocation
ajax_form_title = 'Stock Export Options'
form_class = ExportOptionsForm
def post(self, request, *args, **kwargs):
self.request = request
fmt = request.POST.get('file_format', 'csv').lower()
cascade = str2bool(request.POST.get('include_sublocations', False))
# Format a URL to redirect to
url = reverse('stock-export')
url += '?format=' + fmt
url += '&cascade=' + str(cascade)
data = {
'form_valid': True,
'format': fmt,
'cascade': cascade
}
return self.renderJsonResponse(self.request, self.form_class(), data=data)
def get(self, request, *args, **kwargs):
return self.renderJsonResponse(request, self.form_class())
class StockExport(AjaxView):
""" Export stock data from a particular location.
Returns a file containing stock information for that location.
"""
model = StockItem
def get(self, request, *args, **kwargs):
export_format = request.GET.get('format', 'csv').lower()
# Check if a particular location was specified
loc_id = request.GET.get('location', None)
location = None
if loc_id:
try:
location = StockLocation.objects.get(pk=loc_id)
except (ValueError, StockLocation.DoesNotExist):
pass
# Check if a particular supplier was specified
sup_id = request.GET.get('supplier', None)
supplier = None
if sup_id:
try:
supplier = Company.objects.get(pk=sup_id)
except (ValueError, Company.DoesNotExist):
pass
# Check if a particular part was specified
part_id = request.GET.get('part', None)
part = None
if part_id:
try:
part = Part.objects.get(pk=part_id)
except (ValueError, Part.DoesNotExist):
pass
if export_format not in GetExportFormats():
export_format = 'csv'
filename = 'InvenTree_Stocktake_{date}.{fmt}'.format(
date=datetime.now().strftime("%d-%b-%Y"),
fmt=export_format
)
if location:
# CHeck if locations should be cascading
cascade = str2bool(request.GET.get('cascade', True))
stock_items = location.get_stock_items(cascade)
else:
cascade = True
stock_items = StockItem.objects.all()
if part:
stock_items = stock_items.filter(part=part)
if supplier:
stock_items = stock_items.filter(supplier_part__supplier=supplier)
# Filter out stock items that are not 'in stock'
stock_items = stock_items.filter(customer=None)
stock_items = stock_items.filter(belongs_to=None)
# Pre-fetch related fields to reduce DB queries
stock_items = stock_items.prefetch_related('part', 'supplier_part__supplier', 'location', 'purchase_order', 'build')
# Column headers
headers = [
_('Stock ID'),
_('Part ID'),
_('Part'),
_('Supplier Part ID'),
_('Supplier ID'),
_('Supplier'),
_('Location ID'),
_('Location'),
_('Quantity'),
_('Batch'),
_('Serial'),
_('Status'),
_('Notes'),
_('Review Needed'),
_('Last Updated'),
_('Last Stocktake'),
_('Purchase Order ID'),
_('Build ID'),
]
data = tablib.Dataset(headers=headers)
for item in stock_items:
line = []
line.append(item.pk)
line.append(item.part.pk)
line.append(item.part.full_name)
if item.supplier_part:
line.append(item.supplier_part.pk)
line.append(item.supplier_part.supplier.pk)
line.append(item.supplier_part.supplier.name)
else:
line.append('')
line.append('')
line.append('')
if item.location:
line.append(item.location.pk)
line.append(item.location.name)
else:
line.append('')
line.append('')
line.append(item.quantity)
line.append(item.batch)
line.append(item.serial)
line.append(StockStatus.label(item.status))
line.append(item.notes)
line.append(item.review_needed)
line.append(item.updated)
line.append(item.stocktake_date)
if item.purchase_order:
line.append(item.purchase_order.pk)
else:
line.append('')
if item.build:
line.append(item.build.pk)
else:
line.append('')
data.append(line)
filedata = data.export(export_format)
return DownloadFile(filedata, filename)
class StockItemQRCode(QRCodeView):
""" View for displaying a QR code for a StockItem object """
@@ -148,7 +329,15 @@ class StockAdjust(AjaxView, FormMixin):
stock_items = []
def get_GET_items(self):
""" Return list of stock items initally requested using GET """
""" Return list of stock items initally requested using GET.
Items can be retrieved by:
a) List of stock ID - stock[]=1,2,3,4,5
b) Parent part - part=3
c) Parent location - location=78
d) Single item - item=2
"""
# Start with all 'in stock' items
items = StockItem.objects.filter(customer=None, belongs_to=None)
@@ -224,6 +413,7 @@ class StockAdjust(AjaxView, FormMixin):
if not self.stock_action == 'move':
form.fields.pop('destination')
form.fields.pop('set_loc')
return form
@@ -257,7 +447,7 @@ class StockAdjust(AjaxView, FormMixin):
self.request = request
self.stock_action = request.POST.get('stock_action').lower()
self.stock_action = request.POST.get('stock_action', 'invalid').lower()
# Update list of stock items
self.stock_items = self.get_POST_items()
@@ -297,8 +487,22 @@ class StockAdjust(AjaxView, FormMixin):
}
if valid:
result = self.do_action()
data['success'] = self.do_action()
data['success'] = result
# Special case - Single Stock Item
# If we deplete the stock item, we MUST redirect to a new view
single_item = len(self.stock_items) == 1
if result and single_item:
# Was the entire stock taken?
item = self.stock_items[0]
if item.quantity == 0:
# Instruct the form to redirect
data['url'] = reverse('stock-index')
return self.renderJsonResponse(request, form, data=data)
@@ -308,6 +512,8 @@ class StockAdjust(AjaxView, FormMixin):
if self.stock_action == 'move':
destination = None
set_default_loc = str2bool(self.request.POST.get('set_loc', False))
try:
destination = StockLocation.objects.get(id=self.request.POST.get('destination'))
except StockLocation.DoesNotExist:
@@ -315,7 +521,7 @@ class StockAdjust(AjaxView, FormMixin):
except ValueError:
pass
return self.do_move(destination)
return self.do_move(destination, set_default_loc)
elif self.stock_action == 'add':
return self.do_add()
@@ -372,7 +578,7 @@ class StockAdjust(AjaxView, FormMixin):
return _("Counted stock for {n} items".format(n=count))
def do_move(self, destination):
def do_move(self, destination, set_loc=None):
""" Perform actual stock movement """
count = 0
@@ -383,6 +589,11 @@ class StockAdjust(AjaxView, FormMixin):
# Avoid moving zero quantity
if item.new_quantity <= 0:
continue
# If we wish to set the destination location to the default one
if set_loc:
item.part.default_location = destination
item.part.save()
# Do not move to the same location (unless the quantity is different)
if destination == item.location and item.new_quantity == item.quantity:

View File

@@ -0,0 +1,115 @@
{% extends "InvenTree/settings/settings.html" %}
{% block tabs %}
{% include "InvenTree/settings/tabs.html" with tab='currency' %}
{% endblock %}
{% block settings %}
<h4>Currencies</h4>
<div id='currency-buttons'>
<button class='btn btn-success' id='new-currency'>New Currency</button>
</div>
<table class='table table-striped table-condensed' id='currency-table' data-toolbar='#currency-buttons'>
</table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
$("#currency-table").bootstrapTable({
url: "{% url 'api-currency-list' %}",
queryParams: {
ordering: 'suffix'
},
sortable: true,
search: true,
pagination: true,
pageSize: 25,
formatNoMatches: function() { return "No currencies found"; },
rowStyle: function(row, index) {
if (row.base) {
return {classes: 'basecurrency'};
} else {
return {};
}
},
columns: [
{
field: 'pk',
title: 'ID',
visible: false,
},
{
field: 'symbol',
title: 'Symbol',
},
{
field: 'suffix',
title: 'Currency',
sortable: true,
},
{
field: 'description',
title: 'Description',
sortable: true,
},
{
field: 'value',
title: 'Value',
sortable: true,
formatter: function(value, row, index, field) {
if (row.base) {
return "Base Currency";
} else {
return value;
}
}
},
{
formatter: function(value, row, index, field) {
var bEdit = "<button title='Edit Currency' class='cur-edit btn btn-default btn-glyph' type='button' pk='" + row.pk + "'><span class='glyphicon glyphicon-edit'></span></button>";
var bDel = "<button title='Delete Currency' class='cur-delete btn btn-default btn-glyph' type='button' pk='" + row.pk + "'><span class='glyphicon glyphicon-trash'></span></button>";
var html = "<div class='btn-group' role='group'>" + bEdit + bDel + "</div>";
return html;
}
}
]
});
$("#currency-table").on('click', '.cur-edit', function() {
var button = $(this);
var url = "/common/currency/" + button.attr('pk') + "/edit/";
launchModalForm(url, {
success: function() {
$("#currency-table").bootstrapTable('refresh');
},
});
});
$("#currency-table").on('click', '.cur-delete', function() {
var button = $(this);
var url = "/common/currency/" + button.attr('pk') + "/delete/";
launchModalForm(url, {
success: function() {
$("#currency-table").bootstrapTable('refresh');
},
});
});
$("#new-currency").click(function() {
launchModalForm("{% url 'currency-create' %}", {
success: function() {
$("#currency-table").bootstrapTable('refresh');
},
});
});
{% endblock %}

View File

@@ -0,0 +1,93 @@
{% extends "InvenTree/settings/settings.html" %}
{% block tabs %}
{% include "InvenTree/settings/tabs.html" with tab='part' %}
{% endblock %}
{% block settings %}
<h4>Part Parameter Templates</h4>
<div id='param-buttons'>
<button class='btn btn-success' id='new-param'>New Parameter</button>
</div>
<table class='table table-striped table-condensed' id='param-table' data-toolbar='#param-buttons'>
</table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
$("#param-table").bootstrapTable({
url: "{% url 'api-part-param-template-list' %}",
queryParams: {
ordering: 'name',
},
sortable: true,
search: true,
pagination: true,
pageSize: 25,
formatNoMatches: function() { return "No part parameter templates found"; },
columns: [
{
field: 'pk',
title: 'ID',
visible: false,
},
{
field: 'name',
title: 'Name',
sortable: 'true',
},
{
field: 'units',
title: 'Units',
sortable: 'true',
},
{
formatter: function(value, row, index, field) {
var bEdit = "<button title='Edit Template' class='template-edit btn btn-default btn-glyph' type='button' pk='" + row.pk + "'><span class='glyphicon glyphicon-edit'></span></button>";
var bDel = "<button title='Delete Template' class='template-delete btn btn-default btn-glyph' type='button' pk='" + row.pk + "'><span class='glyphicon glyphicon-trash'></span></button>";
var html = "<div class='btn-group' role='group'>" + bEdit + bDel + "</div>";
return html;
}
}
]
});
$("#new-param").click(function() {
launchModalForm("{% url 'part-param-template-create' %}", {
success: function() {
$("#param-table").bootstrapTable('refresh');
},
});
});
$("#param-table").on('click', '.template-edit', function() {
var button = $(this);
var url = "/part/parameter/template/" + button.attr('pk') + "/edit/";
launchModalForm(url, {
success: function() {
$("#param-table").bootstrapTable('refresh');
}
});
});
$("#param-table").on('click', '.template-delete', function() {
var button = $(this);
var url = "/part/parameter/template/" + button.attr('pk') + "/delete/";
launchModalForm(url, {
success: function() {
$("#param-table").bootstrapTable('refresh');
}
});
});
{% endblock %}

View File

@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% load static %}
{% block page_title %}
InvenTree | Settings
{% endblock %}
{% block content %}
<div class='settings-container'>
<h3>InvenTree Settings</h3>
<hr>
<div class='settings-nav'>
{% block tabs %}
{% include "InvenTree/settings/tabs.html" %}
{% endblock %}
</div>
<div class='settings-content'>
{% block settings %}
{% endblock %}
</div>
</div>
{% endblock %}
{% block js_load %}
{{ block.super }}
{% endblock %}

View File

@@ -0,0 +1,11 @@
<ul class='nav nav-pills nav-stacked'>
<li{% ifequal tab 'user' %} class='active'{% endifequal %}>
<a href="{% url 'settings-user' %}"><span class='glyphicon glyphicon-user'></span> User</a>
</li>
<li{% ifequal tab 'currency' %} class='active'{% endifequal %}>
<a href="{% url 'settings-currency' %}"><span class='glyphicon glyphicon-usd'></span> Currency</a>
</li>
<li{% ifequal tab 'part' %} class='active'{% endifequal %}>
<a href="{% url 'settings-part' %}"><span class='glyphicon glyphicon-briefcase'></span> Part</a>
</li>
</ul>

View File

@@ -1,12 +1,10 @@
{% extends "base.html" %}
{% extends "InvenTree/settings/settings.html" %}
{% block page_title %}
InvenTree | Settings
{% block tabs %}
{% include "InvenTree/settings/tabs.html" with tab='user' %}
{% endblock %}
{% block content %}
<h3>InvenTree Settings</h3>
<hr>
{% block settings %}
<div class='row'>
<div class='col-sm-6'>
@@ -18,8 +16,7 @@ InvenTree | Settings
<div class='btn btn-primary' type='button' id='edit-password' title='Change Password'>Set Password</div>
</div>
</div>
</div>
</div>
<table class='table table-striped table-condensed'>
<tr>
@@ -38,10 +35,6 @@ InvenTree | Settings
{% endblock %}
{% block js_load %}
{{ block.super }}
{% endblock %}
{% block js_ready %}
{{ block.super }}

View File

@@ -17,10 +17,10 @@
<h4>InvenTree Version Information</h4>
<table class='table table-striped table-condensed'>
<tr>
<td>Version</td><td>{% inventree_version %}</td>
<td>Version</td><td><a href="https://github.com/inventree/InvenTree/releases">{% inventree_version %}</a></td>
</tr>
<tr>
<td>Commit Hash</td><td>{% inventree_commit %}</td>
<td>Commit Hash</td><td><a href="https://github.com/inventree/InvenTree/commit/{% inventree_commit %}">{% inventree_commit %}</a></td>
</tr>
<tr>
<td colspan="2"></td>
@@ -28,6 +28,10 @@
<tr>
<td>View Code on GitHub</td><td><a href="{% inventree_github %}">{% inventree_github %}</a></td>
</tr>
<tr>
<td></td>
<td><a href='https://github.com/inventree/InvenTree/issues'><button class='btn btn-default'>Submit Bug Report</button></a></td>
</tr>
</table>
</div>

View File

@@ -0,0 +1 @@
{% if currency %}{{ currency.symbol }}{% endif %}{{ price }}{% if currency %} {{ currency.suffix }}{% endif %}

View File

@@ -2,7 +2,9 @@
<div class='container' style='width: 80%;'>
{% if qr_data %}
<img src="{% qr_url_from_text qr_data size='m' error_correction='q' %}" alt="QR Code">
<div class='qr-container'>
<img class='qr-code' src="{% qr_url_from_text qr_data size='m' image_format='png' error_correction='q' %}" alt="QR Code">
</div>
{% else %}
<b>Error:</b><br>
{{ error_msg }}

View File

@@ -1,5 +1,6 @@
<div id='button-toolbar'>
<div class='button-toolbar container-fluid' style='float: right;'>
<button class='btn btn-success' id='stock-export' title='Export Stock Information'>Export</button>
{% if not part or part.is_template == False %}
<button class="btn btn-success" id='item-create'>New Stock Item</button>
{% endif %}

View File

@@ -5,7 +5,7 @@ from . import views
user_urls = [
url(r'^(?P<pk>[0-9]+)/?$', views.UserDetail.as_view(), name='user-detail'),
url(r'token', views.GetAuthToken.as_view()),
url(r'token', views.GetAuthToken.as_view(), name='api-token'),
url(r'^$', views.UserList.as_view()),
]

View File

@@ -1,10 +1,12 @@
from rest_framework import generics, permissions
from django.contrib.auth.models import User
from django.core.exceptions import ObjectDoesNotExist
from .serializers import UserSerializer
from rest_framework.authtoken.views import ObtainAuthToken
from rest_framework.authtoken.models import Token
from rest_framework.response import Response
from rest_framework import status
class UserDetail(generics.RetrieveAPIView):
@@ -27,15 +29,30 @@ class GetAuthToken(ObtainAuthToken):
""" Return authentication token for an authenticated user. """
def post(self, request, *args, **kwargs):
return self.login(request)
def delete(self, request):
return self.logout(request)
def login(self, request):
serializer = self.serializer_class(data=request.data,
context={'request': request})
serializer.is_valid(raise_exception=True)
user = serializer.validated_data['user']
token, created = Token.objects.get_or_create(user=user)
return Response({
'token': token.key,
'pk': user.pk,
'username': user.username,
'email': user.email
})
def logout(self, request):
try:
request.user.auth_token.delete()
return Response({"success": "Successfully logged out."},
status=status.HTTP_202_ACCEPTED)
except (AttributeError, ObjectDoesNotExist):
return Response({"error": "Bad request"},
status=status.HTTP_400_BAD_REQUEST)

View File

@@ -7,16 +7,21 @@ clean:
rm -rf .tox
rm -f .coverage
update: backup install migrate
# Perform database migrations (after schema changes are made)
migrate:
python3 InvenTree/manage.py makemigrations common
python3 InvenTree/manage.py makemigrations company
python3 InvenTree/manage.py makemigrations part
python3 InvenTree/manage.py makemigrations stock
python3 InvenTree/manage.py makemigrations build
python3 InvenTree/manage.py makemigrations order
python3 InvenTree/manage.py migrate
python3 InvenTree/manage.py migrate --run-syncdb
python3 InvenTree/manage.py makemigrations
cd InvenTree && python3 manage.py migrate
cd InvenTree && python3 manage.py migrate --run-syncdb
python3 InvenTree/manage.py check
cd InvenTree && python3 manage.py collectstatic
# Install all required packages
install:
@@ -40,12 +45,12 @@ style:
# Run unit tests
test:
python3 InvenTree/manage.py check
python3 InvenTree/manage.py test build company part stock order
python3 InvenTree/manage.py test build common company order part stock
# Run code coverage
coverage:
python3 InvenTree/manage.py check
coverage run InvenTree/manage.py test build company part stock order InvenTree
coverage run InvenTree/manage.py test build common company order part stock InvenTree
coverage html
# Install packages required to generate code docs
@@ -61,4 +66,4 @@ backup:
python3 InvenTree/manage.py dbbackup
python3 InvenTree/manage.py mediabackup
.PHONY: clean migrate superuser install mysql style test coverage docreqs docs backup
.PHONY: clean migrate superuser install mysql style test coverage docreqs docs backup update

View File

@@ -11,6 +11,7 @@ InvenTree Source Documentation
Configuration<config>
Deployment<deploy>
Migrate Data<migrate>
Update InvenTree<update>
Backup and Restore<backup>
Modal Forms<forms>
Tables<tables>

View File

@@ -9,6 +9,7 @@ InvenTree Modules
docs/InvenTree/index
docs/build/index
docs/common/index
docs/company/index
docs/part/index
docs/order/index
@@ -18,6 +19,7 @@ The InvenTree Django ecosystem provides the following 'apps' for core functional
* `InvenTree <docs/InvenTree/index.html>`_ - High level management functions
* `Build <docs/build/index.html>`_ - Part build projects
* `Common <docs/common/index.html>`_ - Common modules used by various apps
* `Company <docs/company/index.html>`_ - Company management (suppliers / customers)
* `Part <docs/part/index.html>`_ - Part management
* `Order <docs/order/index.html>`_ - Order management

View File

@@ -83,6 +83,7 @@ Development and Testing
Other shorthand functions are provided for the development and testing process:
* ``make install`` - Install all required underlying packages using PIP
* ``make update`` - Update InvenTree installation (after database configuration)
* ``make superuser`` - Create a superuser account
* ``make migrate`` - Perform database migrations
* ``make mysql`` - Install packages required for MySQL database backend

43
docs/update.rst Normal file
View File

@@ -0,0 +1,43 @@
Update InvenTree
================
.. toctree::
:titlesonly:
:maxdepth: 2
:caption: Update
:hidden:
Adminitrators wishing to update InvenTree to the latest version should follow the instructions below. The commands listed below should be run from the InvenTree root directory.
.. important::
It is advisable to backup the InvenTree database before performing these steps.
Stop Server
-----------
Stop the InvenTree server (e.g. gunicorn)
Update Source
-------------
Update the InvenTree source code to the latest version.
``git pull origin master``
Perform Migrations
------------------
Updating the database is as simple as calling the makefile target:
``make update``
This command performs the following steps:
* Backup database entries and uploaded media files
* Perform required database schema changes
* Collect required static files
Restart Server
--------------
Restart the InvenTree server

View File

@@ -3,6 +3,7 @@ pillow>=5.0.0 # Image manipulation
djangorestframework>=3.6.2 # DRF framework
django-cors-headers>=2.5.3 # CORS headers extension for DRF
django_filter>=1.0.2 # Extended filtering options
django-mptt>=0.10.0 # Modified Preorder Tree Traversal
django-dbbackup==3.2.0 # Database backup / restore functionality
coreapi>=2.3.0 # API documentation
pygments>=2.2.0 # Syntax highlighting
@@ -14,4 +15,5 @@ django-qr-code==1.0.0 # Generate QR codes
flake8==3.3.0 # PEP checking
coverage>=4.5.3 # Unit test coverage
python-coveralls==2.9.1 # Coveralls linking (for Travis)
fuzzywuzzy>=0.17.0 # Fuzzy string matching
fuzzywuzzy>=0.17.0 # Fuzzy string matching
python-Levenshtein>=0.12.0 # Required for fuzzywuzzy