mirror of
https://github.com/inventree/InvenTree.git
synced 2025-12-18 12:56:31 -06:00
Compare commits
115 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4dcbd2fda | ||
|
|
b2c9be1bcd | ||
|
|
da5f2338eb | ||
|
|
f5e34bef7e | ||
|
|
53c5324df6 | ||
|
|
f9ae0f83d1 | ||
|
|
fb2c347fd4 | ||
|
|
9c988310b6 | ||
|
|
108382cc89 | ||
|
|
d8a3c7a81d | ||
|
|
11c946be4d | ||
|
|
fff42e7dbb | ||
|
|
231a669fe5 | ||
|
|
3d5542181a | ||
|
|
e81a4ffacd | ||
|
|
8817b4d692 | ||
|
|
fa8056f4b9 | ||
|
|
9212d6047f | ||
|
|
cc452bc270 | ||
|
|
faf8b9f2f0 | ||
|
|
f4e71d6055 | ||
|
|
2c969ef1c6 | ||
|
|
2f47140e0f | ||
|
|
026108803e | ||
|
|
d7f969613e | ||
|
|
e4fc44c135 | ||
|
|
2a203be5cc | ||
|
|
35ebc69235 | ||
|
|
dac61eafa2 | ||
|
|
a5189b8f3f | ||
|
|
0d6a3d3b28 | ||
|
|
b554af5f10 | ||
|
|
3eb3c43e5c | ||
|
|
678157aac4 | ||
|
|
4d7fba9f14 | ||
|
|
2f11fccb73 | ||
|
|
b3a5dbb5db | ||
|
|
ce706aab9e | ||
|
|
baf096b3e7 | ||
|
|
576226ad30 | ||
|
|
dfb0f67b87 | ||
|
|
32f606627d | ||
|
|
f24496c5a2 | ||
|
|
654fbc3847 | ||
|
|
e1ef7174f9 | ||
|
|
27798cd4ad | ||
|
|
023c386f5e | ||
|
|
b4bbd43bae | ||
|
|
efc08f6824 | ||
|
|
dd5ca32e8e | ||
|
|
daa5a32440 | ||
|
|
446b342480 | ||
|
|
c45fcb45cf | ||
|
|
42ade0e0b7 | ||
|
|
55669c79c2 | ||
|
|
3188b0ab18 | ||
|
|
31562826f4 | ||
|
|
67ea0fa887 | ||
|
|
94ab7c5b0e | ||
|
|
f415e2040e | ||
|
|
13270617b9 | ||
|
|
6752bdc1c6 | ||
|
|
873faee040 | ||
|
|
9726ea4f99 | ||
|
|
89c3ab5e99 | ||
|
|
27878d2d8d | ||
|
|
1b8fb4db44 | ||
|
|
9a61ba4e1e | ||
|
|
9c864aa619 | ||
|
|
e9ba51da52 | ||
|
|
8703ee90c6 | ||
|
|
910d9a15f6 | ||
|
|
b3be5ca5a0 | ||
|
|
dac1264878 | ||
|
|
9bde8bde66 | ||
|
|
eb378e5e5d | ||
|
|
4ff7920296 | ||
|
|
38b88e44bd | ||
|
|
7642a1bb7b | ||
|
|
7fd4359007 | ||
|
|
8fe7284173 | ||
|
|
7659f2de7b | ||
|
|
37d9c59a0e | ||
|
|
81f5714cb1 | ||
|
|
1ea7bdf843 | ||
|
|
0508c2dcaf | ||
|
|
7671eb2b22 | ||
|
|
c96c4d16a3 | ||
|
|
7ef2932f38 | ||
|
|
ecd1681585 | ||
|
|
e903c1858f | ||
|
|
20b37a2d11 | ||
|
|
41806089e3 | ||
|
|
3682e9b5fb | ||
|
|
7314f33d6d | ||
|
|
8ae19cb095 | ||
|
|
0325f042b5 | ||
|
|
a6ee3a59a0 | ||
|
|
af8a96e080 | ||
|
|
09cb82cdc0 | ||
|
|
c6a435eba0 | ||
|
|
9f91797f42 | ||
|
|
32d09d2d37 | ||
|
|
7824b8561d | ||
|
|
aeb25e4c34 | ||
|
|
9f87963fa9 | ||
|
|
273412b63d | ||
|
|
f3e161564d | ||
|
|
8949542baf | ||
|
|
0a6abd21be | ||
|
|
3c70c3a29c | ||
|
|
981884f368 | ||
|
|
5dcfc20d82 | ||
|
|
381e58ab1c | ||
|
|
105b93a0e3 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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):
|
||||
|
||||
67
InvenTree/InvenTree/test_api.py
Normal file
67
InvenTree/InvenTree/test_api.py
Normal 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)
|
||||
@@ -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 """
|
||||
|
||||
@@ -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 """
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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():
|
||||
|
||||
0
InvenTree/common/__init__.py
Normal file
0
InvenTree/common/__init__.py
Normal file
10
InvenTree/common/admin.py
Normal file
10
InvenTree/common/admin.py
Normal 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
39
InvenTree/common/api.py
Normal 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
5
InvenTree/common/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CommonConfig(AppConfig):
|
||||
name = 'common'
|
||||
16
InvenTree/common/fixtures/currency.yaml
Normal file
16
InvenTree/common/fixtures/currency.yaml
Normal 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
24
InvenTree/common/forms.py
Normal 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'
|
||||
]
|
||||
26
InvenTree/common/migrations/0001_initial.py
Normal file
26
InvenTree/common/migrations/0001_initial.py
Normal 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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
17
InvenTree/common/migrations/0002_auto_20190902_2304.py
Normal file
17
InvenTree/common/migrations/0002_auto_20190902_2304.py
Normal 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'},
|
||||
),
|
||||
]
|
||||
19
InvenTree/common/migrations/0003_auto_20190902_2310.py
Normal file
19
InvenTree/common/migrations/0003_auto_20190902_2310.py
Normal 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)]),
|
||||
),
|
||||
]
|
||||
0
InvenTree/common/migrations/__init__.py
Normal file
0
InvenTree/common/migrations/__init__.py
Normal file
79
InvenTree/common/models.py
Normal file
79
InvenTree/common/models.py
Normal 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)
|
||||
22
InvenTree/common/serializers.py
Normal file
22
InvenTree/common/serializers.py
Normal 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'
|
||||
]
|
||||
7
InvenTree/common/templates/common/delete_currency.html
Normal file
7
InvenTree/common/templates/common/delete_currency.html
Normal 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
19
InvenTree/common/tests.py
Normal 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
18
InvenTree/common/urls.py
Normal 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
35
InvenTree/common/views.py
Normal 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"
|
||||
@@ -70,5 +70,6 @@ class EditPriceBreakForm(HelperForm):
|
||||
fields = [
|
||||
'part',
|
||||
'quantity',
|
||||
'cost'
|
||||
'cost',
|
||||
'currency',
|
||||
]
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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/');
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -27,7 +27,7 @@ InvenTree | Purchase Orders
|
||||
$("#po-create").click(function() {
|
||||
launchModalForm("{% url 'purchase-order-create' %}",
|
||||
{
|
||||
reload: true,
|
||||
follow: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
32
InvenTree/part/fixtures/params.yaml
Normal file
32
InvenTree/part/fixtures/params.yaml
Normal 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
|
||||
@@ -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
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
18
InvenTree/part/migrations/0017_bomitem_checksum.py
Normal file
18
InvenTree/part/migrations/0017_bomitem_checksum.py
Normal 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),
|
||||
),
|
||||
]
|
||||
18
InvenTree/part/migrations/0018_auto_20190907_0941.py
Normal file
18
InvenTree/part/migrations/0018_auto_20190907_0941.py
Normal 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),
|
||||
),
|
||||
]
|
||||
37
InvenTree/part/migrations/0019_auto_20190908_0404.py
Normal file
37
InvenTree/part/migrations/0019_auto_20190908_0404.py
Normal 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,
|
||||
),
|
||||
]
|
||||
21
InvenTree/part/migrations/0020_auto_20190908_0404.py
Normal file
21
InvenTree/part/migrations/0020_auto_20190908_0404.py
Normal 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)
|
||||
]
|
||||
30
InvenTree/part/migrations/0021_auto_20190908_0916.py
Normal file
30
InvenTree/part/migrations/0021_auto_20190908_0916.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
20
InvenTree/part/migrations/0022_auto_20190908_0918.py
Normal file
20
InvenTree/part/migrations/0022_auto_20190908_0918.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
42
InvenTree/part/test_param.py
Normal file
42
InvenTree/part/test_param.py
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 """
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
37
InvenTree/stock/migrations/0011_auto_20190908_0404.py
Normal file
37
InvenTree/stock/migrations/0011_auto_20190908_0404.py
Normal 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,
|
||||
),
|
||||
]
|
||||
22
InvenTree/stock/migrations/0012_auto_20190908_0405.py
Normal file
22
InvenTree/stock/migrations/0012_auto_20190908_0405.py
Normal 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)
|
||||
]
|
||||
20
InvenTree/stock/migrations/0013_auto_20190908_0916.py
Normal file
20
InvenTree/stock/migrations/0013_auto_20190908_0916.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
20
InvenTree/stock/migrations/0014_auto_20190908_0918.py
Normal file
20
InvenTree/stock/migrations/0014_auto_20190908_0918.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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' %}",
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)),
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
115
InvenTree/templates/InvenTree/settings/currency.html
Normal file
115
InvenTree/templates/InvenTree/settings/currency.html
Normal 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 %}
|
||||
93
InvenTree/templates/InvenTree/settings/part.html
Normal file
93
InvenTree/templates/InvenTree/settings/part.html
Normal 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 %}
|
||||
32
InvenTree/templates/InvenTree/settings/settings.html
Normal file
32
InvenTree/templates/InvenTree/settings/settings.html
Normal 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 %}
|
||||
11
InvenTree/templates/InvenTree/settings/tabs.html
Normal file
11
InvenTree/templates/InvenTree/settings/tabs.html
Normal 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>
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
1
InvenTree/templates/price.html
Normal file
1
InvenTree/templates/price.html
Normal file
@@ -0,0 +1 @@
|
||||
{% if currency %}{{ currency.symbol }}{% endif %}{{ price }}{% if currency %} {{ currency.suffix }}{% endif %}
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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()),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
15
Makefile
15
Makefile
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
43
docs/update.rst
Normal 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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user