diff --git a/InvenTree/part/fixtures/bom.yaml b/InvenTree/part/fixtures/bom.yaml new file mode 100644 index 0000000000..60ca136ce2 --- /dev/null +++ b/InvenTree/part/fixtures/bom.yaml @@ -0,0 +1,29 @@ +# Construct a BOM for item 100 'Bob' + +# 10 x M2x4 LPHS +- model: part.bomitem + fields: + part: 100 + sub_part: 1 + quantity: 10 + +# 40 x R_2K2_0805 +- model: part.bomitem + fields: + part: 100 + sub_part: 3 + quantity: 40 + +# 25 x C_22N_0805 +- model: part.bomitem + fields: + part: 100 + sub_part: 5 + quantity: 25 + +# 3 x Orphan +- model: part.bomitem + fields: + part: 100 + sub_part: 50 + quantity: 3 \ No newline at end of file diff --git a/InvenTree/part/fixtures/category.yaml b/InvenTree/part/fixtures/category.yaml new file mode 100644 index 0000000000..2f46dcce65 --- /dev/null +++ b/InvenTree/part/fixtures/category.yaml @@ -0,0 +1,64 @@ +# Create some PartCategory objects + +- model: part.partcategory + pk: 1 + fields: + name: Electronics + description: Electronic components + parent: null + default_location: 1 # Home + +- model: part.partcategory + pk: 2 + fields: + name: Resistors + description: Resistors + parent: 1 + default_location: null + +- model: part.partcategory + pk: 3 + fields: + name: Capacitors + description: Capacitors + parent: 1 + default_location: null + +- model: part.partcategory + pk: 4 + fields: + name: IC + description: Integrated Circuits + parent: 1 + default_location: null + +- model: part.partcategory + pk: 5 + fields: + name: MCU + description: Microcontrollers + parent: 4 + default_location: null + +- model: part.partcategory + pk: 6 + fields: + name: Transceivers + description: Communication interfaces + parent: 4 + default_location: null + +- model: part.partcategory + pk: 7 + fields: + name: Mechanical + description: Mechanical componenets + default_location: null + +- model: part.partcategory + pk: 8 + fields: + name: Fasteners + description: Screws, bolts, etc + parent: 7 + default_location: 5 diff --git a/InvenTree/part/fixtures/part.yaml b/InvenTree/part/fixtures/part.yaml new file mode 100644 index 0000000000..f9d7c9f4b4 --- /dev/null +++ b/InvenTree/part/fixtures/part.yaml @@ -0,0 +1,57 @@ +# Create some fasteners + +- model: part.part + fields: + name: 'M2x4 LPHS' + description: 'M2x4 low profile head screw' + category: 8 + +- model: part.part + fields: + name: 'M3x12 SHCS' + description: 'M3x12 socket head cap screw' + category: 8 + +# Create some resistors + +- model: part.part + fields: + name: 'R_2K2_0805' + description: '2.2kOhm resistor in 0805 package' + category: 2 + +- model: part.part + fields: + name: 'R_4K7_0603' + description: '4.7kOhm resistor in 0603 package' + category: 2 + default_location: 2 # Home/Bathroom + +# Create some capacitors +- model: part.part + fields: + name: 'C_22N_0805' + description: '22nF capacitor in 0805 package' + category: 3 + +- model: part.part + fields: + name: 'Widget' + description: 'A watchamacallit' + category: 7 + +- model: part.part + pk: 50 + fields: + name: 'Orphan' + description: 'A part without a category' + category: null + +# A part that can be made from other parts +- model: part.part + pk: 100 + fields: + name: 'Bob' + description: 'Can we build it?' + buildable: true + \ No newline at end of file diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 8bc519d616..d406382856 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -217,6 +217,9 @@ class Part(models.Model): "Part", self.id, reverse('api-part-detail', kwargs={'pk': self.id}), + { + 'name': self.name, + } ) class Meta: @@ -474,8 +477,8 @@ class BomItem(models.Model): unique_together = ('part', 'sub_part') def __str__(self): - return "{par} -> {child} ({n})".format( - par=self.part.name, + return "{n} x {child} to make {parent}".format( + parent=self.part.name, child=self.sub_part.name, n=self.quantity) diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 9a0a49447b..b8d9b7ba6d 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -3,9 +3,6 @@ from rest_framework import status from django.urls import reverse from django.contrib.auth import get_user_model -from .models import Part, PartCategory -from .models import BomItem - class PartAPITest(APITestCase): """ @@ -14,36 +11,29 @@ class PartAPITest(APITestCase): - Tests for PartCategory API """ + fixtures = [ + 'category', + 'part', + 'location', + 'bom', + ] + def setUp(self): # Create a user for auth User = get_user_model() User.objects.create_user('testuser', 'test@testing.com', 'password') self.client.login(username='testuser', password='password') - - # Create some test data - TOP = PartCategory.objects.create(name='Top', description='Top level category') - - A = PartCategory.objects.create(name='A', description='Cat A', parent=TOP) - B = PartCategory.objects.create(name='B', description='Cat B', parent=TOP) - C = PartCategory.objects.create(name='C', description='Cat C', parent=TOP) - - Part.objects.create(name='Top.t', description='t in TOP', category=TOP) - - Part.objects.create(name='A.a', description='a in A', category=A) - Part.objects.create(name='B.b', description='b in B', category=B) - Part.objects.create(name='C.c1', description='c1 in C', category=C) - Part.objects.create(name='C.c2', description='c2 in C', category=C) def test_get_categories(self): - # Test that we can retrieve list of part categories + """ Test that we can retrieve list of part categories """ url = reverse('api-part-category-list') response = self.client.get(url, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data), 4) + self.assertEqual(len(response.data), 8) def test_add_categories(self): - # Check that we can add categories + """ Check that we can add categories """ data = { 'name': 'Animals', 'description': 'All animals go here' @@ -52,31 +42,32 @@ class PartAPITest(APITestCase): url = reverse('api-part-category-list') response = self.client.post(url, data, format='json') self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(response.data['pk'], 5) + + parent = response.data['pk'] # Add some sub-categories to the top-level 'Animals' category for animal in ['cat', 'dog', 'zebra']: data = { 'name': animal, 'description': 'A sort of animal', - 'parent': 5, + 'parent': parent, } response = self.client.post(url, data, format='json') self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(response.data['parent'], 5) + self.assertEqual(response.data['parent'], parent) self.assertEqual(response.data['name'], animal) self.assertEqual(response.data['pathstring'], 'Animals/' + animal) # There should be now 8 categories response = self.client.get(url, format='json') - self.assertEqual(len(response.data), 8) + self.assertEqual(len(response.data), 12) def test_cat_detail(self): url = reverse('api-part-category-detail', kwargs={'pk': 4}) response = self.client.get(url, format='json') # Test that we have retrieved the category - self.assertEqual(response.data['description'], 'Cat C') + self.assertEqual(response.data['description'], 'Integrated Circuits') self.assertEqual(response.data['parent'], 1) # Change some data and post it back @@ -87,16 +78,17 @@ class PartAPITest(APITestCase): response = self.client.patch(url, data, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['description'], 'Changing the description') + self.assertIsNone(response.data['parent']) def test_get_all_parts(self): url = reverse('api-part-list') response = self.client.get(url, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data), 5) + self.assertEqual(len(response.data), 8) def test_get_parts_by_cat(self): url = reverse('api-part-list') - data = {'category': 4} + data = {'category': 2} response = self.client.get(url, data, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -104,7 +96,7 @@ class PartAPITest(APITestCase): self.assertEqual(len(response.data), 2) for part in response.data: - self.assertEqual(part['category'], 4) + self.assertEqual(part['category'], 2) def test_include_children(self): """ Test the special 'include_child_categories' flag @@ -116,7 +108,7 @@ class PartAPITest(APITestCase): response = self.client.get(url, data, format='json') # There should be 1 part in this category - self.assertEqual(len(response.data), 1) + self.assertEqual(len(response.data), 0) data['include_child_categories'] = 1 @@ -125,35 +117,10 @@ class PartAPITest(APITestCase): # Now there should be 5 total parts self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data), 5) - - -class BomAPITest(APITestCase): - - def setUp(self): - # Create a user for auth - User = get_user_model() - User.objects.create_user('testuser', 'test@testing.com', 'password') - - self.client.login(username='testuser', password='password') - - # Create some parts - m1 = Part.objects.create(name='A thing', description='Made from other parts', buildable=True) - m2 = Part.objects.create(name='Another thing', description='Made from other parts', buildable=True) - - s1 = Part.objects.create(name='Sub 1', description='Required to make a thing') - s2 = Part.objects.create(name='Sub 2', description='Required to make a thing') - s3 = Part.objects.create(name='Sub 3', description='Required to make a thing') - - # Link BOM items together - BomItem.objects.create(part=m1, sub_part=s1, quantity=10) - BomItem.objects.create(part=m1, sub_part=s2, quantity=100) - BomItem.objects.create(part=m1, sub_part=s3, quantity=40) - - BomItem.objects.create(part=m2, sub_part=s3, quantity=7) + self.assertEqual(len(response.data), 3) def test_get_bom_list(self): - # There should be 4 BomItem objects in the database + """ There should be 4 BomItem objects in the database """ url = reverse('api-bom-list') response = self.client.get(url, format='json') self.assertEqual(len(response.data), 4) @@ -163,7 +130,7 @@ class BomAPITest(APITestCase): url = reverse('api-bom-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'], 40) + self.assertEqual(response.data['quantity'], 25) # Increase the quantity data = response.data @@ -181,7 +148,7 @@ class BomAPITest(APITestCase): url = reverse('api-bom-list') data = { - 'part': 2, + 'part': 100, 'sub_part': 4, 'quantity': 777, } @@ -194,9 +161,7 @@ class BomAPITest(APITestCase): response = self.client.post(url, data, format='json') self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - # Now try to create a BomItem which references itself + # TODO - Now try to create a BomItem which references itself data['part'] = 2 data['sub_part'] = 2 response = self.client.post(url, data, format='json') - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn('cannot be added to its own', str(response.data['sub_part'][0])) diff --git a/InvenTree/part/test_bom_item.py b/InvenTree/part/test_bom_item.py index e3d7aa78ce..6c1d91810c 100644 --- a/InvenTree/part/test_bom_item.py +++ b/InvenTree/part/test_bom_item.py @@ -1,7 +1,54 @@ from django.test import TestCase +import django.core.exceptions as django_exceptions + +from .models import Part, BomItem class BomItemTest(TestCase): + fixtures = [ + 'category', + 'part', + 'location', + 'bom', + ] + def setUp(self): - pass + self.bob = Part.objects.get(id=100) + self.orphan = Part.objects.get(name='Orphan') + + def test_str(self): + b = BomItem.objects.get(id=1) + self.assertEqual(str(b), '10 x M2x4 LPHS to make Bob') + + def test_has_bom(self): + self.assertFalse(self.orphan.has_bom) + self.assertTrue(self.bob.has_bom) + + self.assertEqual(self.bob.bom_count, 4) + + def test_in_bom(self): + parts = self.bob.required_parts() + + self.assertIn(self.orphan, parts) + + def test_bom_export(self): + parts = self.bob.required_parts() + + data = self.bob.export_bom(format='csv') + + for p in parts: + self.assertIn(p.name, data) + self.assertIn(p.description, data) + + def test_used_in(self): + self.assertEqual(self.bob.used_in_count, 0) + self.assertEqual(self.orphan.used_in_count, 1) + + def test_self_reference(self): + """ Test that we get an appropriate error when we create a BomItem which points to itself """ + + with self.assertRaises(django_exceptions.ValidationError): + # A validation error should be raised here + item = BomItem.objects.create(part=self.bob, sub_part=self.bob, quantity=7) + item.clean() diff --git a/InvenTree/part/test_category.py b/InvenTree/part/test_category.py index 3b732f80e8..ddbcf3babc 100644 --- a/InvenTree/part/test_category.py +++ b/InvenTree/part/test_category.py @@ -6,71 +6,126 @@ from .models import Part, PartCategory class CategoryTest(TestCase): """ Tests to ensure that the relational category tree functions correctly. + + Loads the following test fixtures: + - category.yaml """ + fixtures = [ + 'category', + 'part', + 'location', + ] def setUp(self): - self.p1 = PartCategory.objects.create(name='A', - description='Most highest level', - parent=None) - - self.p2 = PartCategory.objects.create(name='B', - description='Sits under second', - parent=self.p1) - - self.p3 = PartCategory.objects.create(name='C', - description='Third tier category', - parent=self.p2) - - # Add two parts in p2 - Part.objects.create(name='Flange', category=self.p2) - Part.objects.create(name='Flob', category=self.p2) - - # Add one part in p3 - Part.objects.create(name='Blob', category=self.p3) + # Extract some interesting categories for time-saving + self.electronics = PartCategory.objects.get(name='Electronics') + self.mechanical = PartCategory.objects.get(name='Mechanical') + self.resistors = PartCategory.objects.get(name='Resistors') + self.capacitors = PartCategory.objects.get(name='Capacitors') + self.fasteners = PartCategory.objects.get(name='Fasteners') + self.ic = PartCategory.objects.get(name='IC') + self.transceivers = PartCategory.objects.get(name='Transceivers') def test_parents(self): - self.assertEqual(self.p1.parent, None) - self.assertEqual(self.p2.parent, self.p1) - self.assertEqual(self.p3.parent, self.p2) + """ Test that the parent fields are properly set, + based on the test fixtures """ + + self.assertEqual(self.resistors.parent, self.electronics) + self.assertEqual(self.capacitors.parent, self.electronics) + self.assertEqual(self.electronics.parent, None) + + self.assertEqual(self.fasteners.parent, self.mechanical) def test_children_count(self): - self.assertEqual(self.p1.has_children, True) - self.assertEqual(self.p2.has_children, True) - self.assertEqual(self.p3.has_children, False) + """ Test that categories have the correct number of children """ + + self.assertTrue(self.electronics.has_children) + self.assertTrue(self.mechanical.has_children) + + self.assertEqual(len(self.electronics.children.all()), 3) + self.assertEqual(len(self.mechanical.children.all()), 1) def test_unique_childs(self): - childs = self.p1.getUniqueChildren() + """ Test the 'unique_children' functionality """ - self.assertIn(self.p2.id, childs) - self.assertIn(self.p3.id, childs) + childs = self.electronics.getUniqueChildren() + + self.assertIn(self.transceivers.id, childs) + self.assertIn(self.ic.id, childs) + + self.assertNotIn(self.fasteners.id, childs) def test_unique_parents(self): - parents = self.p2.getUniqueParents() + """ Test the 'unique_parents' functionality """ + + parents = self.transceivers.getUniqueParents() - self.assertIn(self.p1.id, parents) + self.assertIn(self.electronics.id, parents) + self.assertIn(self.ic.id, parents) + self.assertNotIn(self.fasteners.id, parents) def test_path_string(self): - self.assertEqual(str(self.p3), 'A/B/C') + """ Test that the category path string works correctly """ + + self.assertEqual(str(self.resistors), 'Electronics/Resistors') + self.assertEqual(str(self.transceivers), 'Electronics/IC/Transceivers') def test_url(self): - self.assertEqual(self.p1.get_absolute_url(), '/part/category/1/') + """ Test that the PartCategory URL works """ + + self.assertEqual(self.capacitors.get_absolute_url(), '/part/category/3/') def test_part_count(self): - # No direct parts in the top-level category - self.assertEqual(self.p1.has_parts, False) - self.assertEqual(self.p2.has_parts, True) - self.assertEqual(self.p3.has_parts, True) + """ Test that the Category part count works """ - self.assertEqual(self.p1.partcount, 3) - self.assertEqual(self.p2.partcount, 3) - self.assertEqual(self.p3.partcount, 1) + self.assertTrue(self.resistors.has_parts) + self.assertTrue(self.fasteners.has_parts) + self.assertFalse(self.transceivers.has_parts) + + self.assertEqual(self.fasteners.partcount, 2) + self.assertEqual(self.capacitors.partcount, 1) + + self.assertEqual(self.electronics.partcount, 3) def test_delete(self): - self.assertEqual(Part.objects.filter(category=self.p1).count(), 0) + """ Test that category deletion moves the children properly """ - # Delete p2 (it has 2 direct parts and one child category) - self.p2.delete() + # Delete the 'IC' category and 'Transceiver' should move to be under 'Electronics' + self.assertEqual(self.transceivers.parent, self.ic) + self.assertEqual(self.ic.parent, self.electronics) - self.assertEqual(Part.objects.filter(category=self.p1).count(), 2) + self.ic.delete() - self.assertEqual(PartCategory.objects.get(pk=self.p3.id).parent, self.p1) + # Get the data again + transceivers = PartCategory.objects.get(name='Transceivers') + self.assertEqual(transceivers.parent, self.electronics) + + # Now delete the 'fasteners' category - the parts should move to 'mechanical' + self.fasteners.delete() + + fasteners = Part.objects.filter(description__contains='screw') + + for f in fasteners: + self.assertEqual(f.category, self.mechanical) + + def test_default_locations(self): + """ Test traversal for default locations """ + + self.assertEqual(str(self.fasteners.default_location), 'Office/Drawer') + + # 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') + + # Any part under electronics should default to 'Home' + R1 = Part.objects.get(name='R_2K2_0805') + self.assertIsNone(R1.default_location) + self.assertEqual(R1.get_default_location().name, 'Home') + + # But one part has a default_location set + R2 = Part.objects.get(name='R_4K7_0603') + self.assertEqual(R2.get_default_location().name, 'Bathroom') + + # And one part should have no default location at all + W = Part.objects.get(name='Widget') + self.assertIsNone(W.get_default_location()) diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index 93a8652667..486d7c5684 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -2,37 +2,69 @@ from django.test import TestCase import os -from .models import Part, PartCategory +from .models import Part from .models import rename_part_image +from .templatetags import inventree_extras -class SimplePartTest(TestCase): +class TemplateTagTest(TestCase): + """ Tests for the custom template tag code """ + + def test_multiply(self): + self.assertEqual(inventree_extras.multiply(3, 5), 15) + + def test_version(self): + self.assertEqual(type(inventree_extras.inventree_version()), str) + + def test_hash(self): + hash = inventree_extras.inventree_commit() + self.assertEqual(len(hash), 7) + + def test_github(self): + self.assertIn('github.com', inventree_extras.inventree_github()) + + +class PartTest(TestCase): + """ Tests for the Part model """ + + fixtures = [ + 'category', + 'part', + 'location', + ] def setUp(self): + self.R1 = Part.objects.get(name='R_2K2_0805') + self.R2 = Part.objects.get(name='R_4K7_0603') - cat = PartCategory.objects.create(name='TLC', description='Top level category') - - self.px = Part.objects.create(name='x', description='A part called x', buildable=True) - self.py = Part.objects.create(name='y', description='A part called y', consumable=False) - self.pz = Part.objects.create(name='z', description='A part called z', category=cat) + self.C1 = Part.objects.get(name='C_22N_0805') def test_metadata(self): - self.assertEqual(self.px.name, 'x') - self.assertEqual(self.py.get_absolute_url(), '/part/2/') - self.assertEqual(str(self.pz), 'z - A part called z') + self.assertEqual(self.R1.name, 'R_2K2_0805') + self.assertEqual(self.R1.get_absolute_url(), '/part/3/') def test_category(self): - self.assertEqual(self.px.category_path, '') - self.assertEqual(self.pz.category_path, 'TLC') + self.assertEqual(str(self.C1.category), 'Electronics/Capacitors') + + orphan = Part.objects.get(name='Orphan') + self.assertIsNone(orphan.category) + self.assertEqual(orphan.category_path, '') def test_rename_img(self): - img = rename_part_image(self.px, 'hello.png') - self.assertEqual(img, os.path.join('part_images', 'part_1_img.png')) + img = rename_part_image(self.R1, 'hello.png') + self.assertEqual(img, os.path.join('part_images', 'part_3_img.png')) - img = rename_part_image(self.pz, 'test') - self.assertEqual(img, os.path.join('part_images', 'part_3_img')) + img = rename_part_image(self.R2, 'test') + self.assertEqual(img, os.path.join('part_images', 'part_4_img')) def test_stock(self): - # Stock should initially be zero - self.assertEqual(self.px.total_stock, 0) - self.assertEqual(self.py.available_stock, 0) + # No stock of any resistors + res = Part.objects.filter(description__contains='resistor') + for r in res: + self.assertEqual(r.total_stock, 0) + self.assertEqual(r.available_stock, 0) + + def test_barcode(self): + barcode = self.R1.format_barcode() + self.assertIn('InvenTree', barcode) + self.assertIn(self.R1.name, barcode) diff --git a/InvenTree/stock/fixtures/location.yaml b/InvenTree/stock/fixtures/location.yaml new file mode 100644 index 0000000000..f04b192a7d --- /dev/null +++ b/InvenTree/stock/fixtures/location.yaml @@ -0,0 +1,31 @@ +# Create some locations for stock + +- model: stock.stocklocation + pk: 1 + fields: + name: 'Home' + description: 'My house' +- model: stock.stocklocation + pk: 2 + fields: + name: 'Bathroom' + description: 'Where I keep my bath' + parent: 1 +- model: stock.stocklocation + pk: 3 + fields: + name: 'Dining Room' + description: 'A table lives here' + parent: 1 + +- model: stock.stocklocation + pk: 4 + fields: + name: 'Office' + description: 'Place of work' +- model: stock.stocklocation + pk: 5 + fields: + name: 'Drawer' + description: 'In my desk' + parent: 4 \ No newline at end of file