From aa30e62ad80b120d3b36bf2024cd844a060263a9 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 30 Mar 2022 01:29:36 +0200 Subject: [PATCH 01/47] [BUG] Deleting a Customer Breaks Associated Sales Orders Add special protected deleted company Fixes #2788 --- .../migrations/0043_company_is_deleted.py | 18 +++++++++++++ InvenTree/company/models.py | 16 +++++++++++- .../migrations/0064_auto_20220329_2246.py | 25 +++++++++++++++++++ InvenTree/order/models.py | 7 ++++-- 4 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 InvenTree/company/migrations/0043_company_is_deleted.py create mode 100644 InvenTree/order/migrations/0064_auto_20220329_2246.py diff --git a/InvenTree/company/migrations/0043_company_is_deleted.py b/InvenTree/company/migrations/0043_company_is_deleted.py new file mode 100644 index 0000000000..cf42f7cc6a --- /dev/null +++ b/InvenTree/company/migrations/0043_company_is_deleted.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.12 on 2022-03-29 22:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('company', '0042_supplierpricebreak_updated'), + ] + + operations = [ + migrations.AddField( + model_name='company', + name='is_deleted', + field=models.BooleanField(default=False, help_text='Is this company a deleted placeholder?', verbose_name='is deleted'), + ), + ] diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index f72668f9f0..8058b7e358 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -9,7 +9,7 @@ import os from django.utils.translation import ugettext_lazy as _ from django.core.validators import MinValueValidator -from django.core.exceptions import ValidationError +from django.core.exceptions import ValidationError, PermissionDenied from django.db import models from django.db.models import Sum, Q, UniqueConstraint @@ -147,6 +147,8 @@ class Company(models.Model): is_manufacturer = models.BooleanField(default=False, verbose_name=_('is manufacturer'), help_text=_('Does this company manufacture parts?')) + is_deleted = models.BooleanField(default=False, verbose_name=_('is deleted'), help_text=_('Is this company a deleted placeholder?')) + currency = models.CharField( max_length=3, verbose_name=_('Currency'), @@ -266,6 +268,18 @@ class Company(models.Model): return self.purchase_orders.filter(status__in=PurchaseOrderStatus.FAILED) + def save(self, *args, **kwargs): + """Save the instance, unless it is the magic already deleted object""" + if self.pk and self.is_deleted: + raise PermissionDenied(_('This company is a placeholder and can not be updated')) + return super().save(*args, **kwargs) + + def delete(self, *args, **kwargs): + """Delete the instance, unless it is the magic already deleted object""" + if self.is_deleted: + raise PermissionDenied(_('This company is a placeholder and can not be deleted')) + return super().delete(*args, **kwargs) + class Contact(models.Model): """ A Contact represents a person who works at a particular company. diff --git a/InvenTree/order/migrations/0064_auto_20220329_2246.py b/InvenTree/order/migrations/0064_auto_20220329_2246.py new file mode 100644 index 0000000000..ad0a804d11 --- /dev/null +++ b/InvenTree/order/migrations/0064_auto_20220329_2246.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.12 on 2022-03-29 22:46 + +from django.db import migrations, models +import order.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('company', '0043_company_is_deleted'), + ('order', '0063_alter_purchaseorderlineitem_unique_together'), + ] + + operations = [ + migrations.AlterField( + model_name='purchaseorder', + name='supplier', + field=models.ForeignKey(help_text='Company from which the items are being ordered', limit_choices_to={'is_supplier': True}, on_delete=models.SET(order.models.get_deleted_company), related_name='purchase_orders', to='company.company', verbose_name='Supplier'), + ), + migrations.AlterField( + model_name='salesorder', + name='customer', + field=models.ForeignKey(help_text='Company to which the items are being sold', limit_choices_to={'is_customer': True}, null=True, on_delete=models.SET(order.models.get_deleted_company), related_name='sales_orders', to='company.company', verbose_name='Customer'), + ), + ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index f08880a882..9225f3faf8 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -92,6 +92,9 @@ def get_next_so_number(): return reference +def get_deleted_company(): + return Company.objects.get_or_create(name='deleted', email='deleted',is_deleted=True)[0] + class Order(ReferenceIndexingMixin): """ Abstract model for an order. @@ -219,7 +222,7 @@ class PurchaseOrder(Order): help_text=_('Purchase order status')) supplier = models.ForeignKey( - Company, on_delete=models.CASCADE, + Company, on_delete=models.SET(get_deleted_company), limit_choices_to={ 'is_supplier': True, }, @@ -567,7 +570,7 @@ class SalesOrder(Order): customer = models.ForeignKey( Company, - on_delete=models.SET_NULL, + on_delete=models.SET(get_deleted_company), null=True, limit_choices_to={'is_customer': True}, related_name='sales_orders', From 6c6c47c60e29f7ff113427e01f5b7d3e57203280 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 30 Mar 2022 01:30:02 +0200 Subject: [PATCH 02/47] do not import / export --- InvenTree/company/admin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/InvenTree/company/admin.py b/InvenTree/company/admin.py index 97327a559a..5a19d2c391 100644 --- a/InvenTree/company/admin.py +++ b/InvenTree/company/admin.py @@ -21,6 +21,7 @@ class CompanyResource(ModelResource): class Meta: model = Company + exclude = ('is_deleted', ) skip_unchanged = True report_skipped = False clean_model_instances = True From d068b0a064cd522506646975e3b441c33b56fbb4 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 30 Mar 2022 01:30:21 +0200 Subject: [PATCH 03/47] do not show in admin --- InvenTree/company/admin.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/InvenTree/company/admin.py b/InvenTree/company/admin.py index 5a19d2c391..af78e76a7f 100644 --- a/InvenTree/company/admin.py +++ b/InvenTree/company/admin.py @@ -38,6 +38,8 @@ class CompanyAdmin(ImportExportModelAdmin): 'description', ] + exclude = ('is_deleted',) + class SupplierPartResource(ModelResource): """ From 44239cba081fd3afbce3401fed6721898e25c15d Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 30 Mar 2022 01:30:32 +0200 Subject: [PATCH 04/47] hide delete button --- InvenTree/company/admin.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/InvenTree/company/admin.py b/InvenTree/company/admin.py index af78e76a7f..6412aff934 100644 --- a/InvenTree/company/admin.py +++ b/InvenTree/company/admin.py @@ -40,6 +40,11 @@ class CompanyAdmin(ImportExportModelAdmin): exclude = ('is_deleted',) + def has_delete_permission(self, request, obj=None): + if obj and obj.is_deleted: + return False + return True + class SupplierPartResource(ModelResource): """ From 0d2fbe16f5d2ae583478767fb236231059a3a4c4 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 30 Mar 2022 01:31:02 +0200 Subject: [PATCH 05/47] hide the action buttons if special object --- InvenTree/company/templates/company/company_base.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/InvenTree/company/templates/company/company_base.html b/InvenTree/company/templates/company/company_base.html index 489493fd06..4caf37272e 100644 --- a/InvenTree/company/templates/company/company_base.html +++ b/InvenTree/company/templates/company/company_base.html @@ -27,12 +27,12 @@ +{% endif %} {% endblock actions %} {% block thumbnail %} From 924e46a0e357abb5444732abf352e580447676ae Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 30 Mar 2022 01:32:09 +0200 Subject: [PATCH 07/47] hide purchase buttopn if special button --- InvenTree/company/templates/company/company_base.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/company/templates/company/company_base.html b/InvenTree/company/templates/company/company_base.html index 6bf4c929b3..4adf4adb3b 100644 --- a/InvenTree/company/templates/company/company_base.html +++ b/InvenTree/company/templates/company/company_base.html @@ -18,7 +18,7 @@ {% url 'admin:company_company_change' company.pk as url %} {% include "admin_button.html" with url=url %} {% endif %} -{% if company.is_supplier and roles.purchase_order.add %} +{% if company.is_supplier and roles.purchase_order.add and not company.is_deleted %} From 48441ea48e51fe2a61625ce9146b61f6b00cdc36 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 30 Mar 2022 01:32:29 +0200 Subject: [PATCH 08/47] add docstring --- InvenTree/order/models.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 9225f3faf8..a9db03ca54 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -93,6 +93,9 @@ def get_next_so_number(): def get_deleted_company(): + """ + Returns the deleted company object + """ return Company.objects.get_or_create(name='deleted', email='deleted',is_deleted=True)[0] class Order(ReferenceIndexingMixin): From e7f940810ad926b8f1fd82c078f1255d4827d259 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 30 Mar 2022 01:32:41 +0200 Subject: [PATCH 09/47] PEP style fix --- InvenTree/order/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index a9db03ca54..270e1ca223 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -98,6 +98,7 @@ def get_deleted_company(): """ return Company.objects.get_or_create(name='deleted', email='deleted',is_deleted=True)[0] + class Order(ReferenceIndexingMixin): """ Abstract model for an order. From 1d24f3586d3b95bcdc7d9947acd07d6d019c6ddb Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 30 Mar 2022 01:35:21 +0200 Subject: [PATCH 10/47] PEP fix --- InvenTree/order/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 270e1ca223..ee18fb9a21 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -96,7 +96,7 @@ def get_deleted_company(): """ Returns the deleted company object """ - return Company.objects.get_or_create(name='deleted', email='deleted',is_deleted=True)[0] + return Company.objects.get_or_create(name='deleted', email='deleted', is_deleted=True)[0] class Order(ReferenceIndexingMixin): From c278a5397f1fa65fb37842ec9d66a57a5138bda7 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 30 Mar 2022 01:42:00 +0200 Subject: [PATCH 11/47] add doc --- InvenTree/company/admin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/InvenTree/company/admin.py b/InvenTree/company/admin.py index 6412aff934..af767f3fdc 100644 --- a/InvenTree/company/admin.py +++ b/InvenTree/company/admin.py @@ -41,6 +41,7 @@ class CompanyAdmin(ImportExportModelAdmin): exclude = ('is_deleted',) def has_delete_permission(self, request, obj=None): + """Magic objects are not allowd to be deleted""" if obj and obj.is_deleted: return False return True From 57f9ef75e9bb2cf36ccaa9ab291691527ea3acef Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 1 Apr 2022 23:55:08 +0200 Subject: [PATCH 12/47] enable all functions for deleted company --- InvenTree/order/models.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index ee18fb9a21..9dcb360bcd 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -96,7 +96,14 @@ def get_deleted_company(): """ Returns the deleted company object """ - return Company.objects.get_or_create(name='deleted', email='deleted', is_deleted=True)[0] + return Company.objects.get_or_create( + name='deleted', + email='deleted', + is_deleted=True, + is_customer = True, + is_supplier = True, + is_manufacturer = True + )[0] class Order(ReferenceIndexingMixin): From 80fa8f6d1849e175cc9aece62f85bc66af3fc708 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 1 Apr 2022 23:57:49 +0200 Subject: [PATCH 13/47] remove double definition --- InvenTree/company/models.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index 8058b7e358..7bb3a0701d 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -304,9 +304,6 @@ class Contact(models.Model): role = models.CharField(max_length=100, blank=True) - company = models.ForeignKey(Company, related_name='contacts', - on_delete=models.CASCADE) - class ManufacturerPart(models.Model): """ Represents a unique part as provided by a Manufacturer From 66e14b6ad02051f7e5dda868d97695f3dcc56aa1 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 2 Apr 2022 00:01:38 +0200 Subject: [PATCH 14/47] move helper function to models.py --- InvenTree/company/models.py | 14 ++++++++++++++ InvenTree/order/models.py | 16 +--------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index 7bb3a0701d..8d010f0457 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -60,6 +60,20 @@ def rename_company_image(instance, filename): return os.path.join(base, fn) +def get_deleted_company(): + """ + Returns the deleted company object + """ + return Company.objects.get_or_create( + name='deleted', + email='deleted', + is_deleted=True, + is_customer = True, + is_supplier = True, + is_manufacturer = True + )[0] + + class Company(models.Model): """ A Company object represents an external company. It may be a supplier or a customer or a manufacturer (or a combination) diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 9dcb360bcd..b9bec62890 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -24,7 +24,7 @@ from mptt.models import TreeForeignKey from users import models as UserModels from part import models as PartModels from stock import models as stock_models -from company.models import Company, SupplierPart +from company.models import Company, SupplierPart, get_deleted_company from plugin.events import trigger_event import InvenTree.helpers @@ -92,20 +92,6 @@ def get_next_so_number(): return reference -def get_deleted_company(): - """ - Returns the deleted company object - """ - return Company.objects.get_or_create( - name='deleted', - email='deleted', - is_deleted=True, - is_customer = True, - is_supplier = True, - is_manufacturer = True - )[0] - - class Order(ReferenceIndexingMixin): """ Abstract model for an order. From 07aaa457de573c0d85b9735416dce44c58ec8401 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 2 Apr 2022 00:04:25 +0200 Subject: [PATCH 15/47] PEP fix --- InvenTree/company/models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index 8d010f0457..7866fbc8a7 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -68,9 +68,9 @@ def get_deleted_company(): name='deleted', email='deleted', is_deleted=True, - is_customer = True, - is_supplier = True, - is_manufacturer = True + is_customer=True, + is_supplier=True, + is_manufacturer=True )[0] From 132f4aa82e427c2befdf7b6ebcaf2959bb03ef45 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 1 May 2022 00:07:13 +0200 Subject: [PATCH 16/47] Use set_null instead --- InvenTree/company/admin.py | 9 --------- InvenTree/company/models.py | 28 ---------------------------- InvenTree/order/models.py | 6 +++--- 3 files changed, 3 insertions(+), 40 deletions(-) diff --git a/InvenTree/company/admin.py b/InvenTree/company/admin.py index af767f3fdc..97327a559a 100644 --- a/InvenTree/company/admin.py +++ b/InvenTree/company/admin.py @@ -21,7 +21,6 @@ class CompanyResource(ModelResource): class Meta: model = Company - exclude = ('is_deleted', ) skip_unchanged = True report_skipped = False clean_model_instances = True @@ -38,14 +37,6 @@ class CompanyAdmin(ImportExportModelAdmin): 'description', ] - exclude = ('is_deleted',) - - def has_delete_permission(self, request, obj=None): - """Magic objects are not allowd to be deleted""" - if obj and obj.is_deleted: - return False - return True - class SupplierPartResource(ModelResource): """ diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index 7866fbc8a7..e4b3a1b640 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -60,20 +60,6 @@ def rename_company_image(instance, filename): return os.path.join(base, fn) -def get_deleted_company(): - """ - Returns the deleted company object - """ - return Company.objects.get_or_create( - name='deleted', - email='deleted', - is_deleted=True, - is_customer=True, - is_supplier=True, - is_manufacturer=True - )[0] - - class Company(models.Model): """ A Company object represents an external company. It may be a supplier or a customer or a manufacturer (or a combination) @@ -161,8 +147,6 @@ class Company(models.Model): is_manufacturer = models.BooleanField(default=False, verbose_name=_('is manufacturer'), help_text=_('Does this company manufacture parts?')) - is_deleted = models.BooleanField(default=False, verbose_name=_('is deleted'), help_text=_('Is this company a deleted placeholder?')) - currency = models.CharField( max_length=3, verbose_name=_('Currency'), @@ -282,18 +266,6 @@ class Company(models.Model): return self.purchase_orders.filter(status__in=PurchaseOrderStatus.FAILED) - def save(self, *args, **kwargs): - """Save the instance, unless it is the magic already deleted object""" - if self.pk and self.is_deleted: - raise PermissionDenied(_('This company is a placeholder and can not be updated')) - return super().save(*args, **kwargs) - - def delete(self, *args, **kwargs): - """Delete the instance, unless it is the magic already deleted object""" - if self.is_deleted: - raise PermissionDenied(_('This company is a placeholder and can not be deleted')) - return super().delete(*args, **kwargs) - class Contact(models.Model): """ A Contact represents a person who works at a particular company. diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index b9bec62890..2eb5b0d69c 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -24,7 +24,7 @@ from mptt.models import TreeForeignKey from users import models as UserModels from part import models as PartModels from stock import models as stock_models -from company.models import Company, SupplierPart, get_deleted_company +from company.models import Company, SupplierPart from plugin.events import trigger_event import InvenTree.helpers @@ -219,7 +219,7 @@ class PurchaseOrder(Order): help_text=_('Purchase order status')) supplier = models.ForeignKey( - Company, on_delete=models.SET(get_deleted_company), + Company, on_delete=models.SET_NULL, limit_choices_to={ 'is_supplier': True, }, @@ -567,7 +567,7 @@ class SalesOrder(Order): customer = models.ForeignKey( Company, - on_delete=models.SET(get_deleted_company), + on_delete=models.SET_NULL, null=True, limit_choices_to={'is_customer': True}, related_name='sales_orders', From 9947cc2b08721e2727bf7a95ce1b65ee0b7983e1 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 1 May 2022 00:07:37 +0200 Subject: [PATCH 17/47] remove old permissionset --- InvenTree/company/templates/company/company_base.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/InvenTree/company/templates/company/company_base.html b/InvenTree/company/templates/company/company_base.html index 4adf4adb3b..c58ea63791 100644 --- a/InvenTree/company/templates/company/company_base.html +++ b/InvenTree/company/templates/company/company_base.html @@ -18,23 +18,23 @@ {% url 'admin:company_company_change' company.pk as url %} {% include "admin_button.html" with url=url %} {% endif %} -{% if company.is_supplier and roles.purchase_order.add and not company.is_deleted %} +{% if company.is_supplier and roles.purchase_order.add %} {% endif %} {% define perms.company.change_company or perms.company.delete_company as has_permission %} -{% if not company.is_deleted and has_permission %} +{% if has_permission %} From 17c84141b11e6192419858c085f98bbee7a9ea49 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 4 May 2022 17:38:01 +1000 Subject: [PATCH 38/47] Javascript linting --- InvenTree/templates/js/translated/forms.js | 2 +- InvenTree/templates/js/translated/order.js | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index df5c864b50..642523a60b 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -124,7 +124,7 @@ function getApiEndpointOptions(url, callback) { } // Include extra context information in the request - url += '?context=true' + url += '?context=true'; // Return the ajax request object $.ajax({ diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 6b163afa1e..538f37a710 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -28,7 +28,7 @@ createSalesOrderShipment, editPurchaseOrderLineItem, exportOrder, - issurPurchaseOrder, + issuePurchaseOrder, loadPurchaseOrderLineItemTable, loadPurchaseOrderExtraLineTable loadPurchaseOrderTable, @@ -242,8 +242,7 @@ function issuePurchaseOrder(order_id, options={}) { handleFormSuccess(response, options); } } - ) - + ); } From 104f9d4a70740e2ad024f5c833041809f56a2ae2 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 4 May 2022 18:39:33 +1000 Subject: [PATCH 39/47] Remove outdated unit tests --- InvenTree/build/test_api.py | 81 +++++++++++++++++++++++++ InvenTree/build/tests.py | 107 ---------------------------------- InvenTree/order/test_views.py | 27 --------- 3 files changed, 81 insertions(+), 134 deletions(-) diff --git a/InvenTree/build/test_api.py b/InvenTree/build/test_api.py index 9c20dea580..9551e3d07e 100644 --- a/InvenTree/build/test_api.py +++ b/InvenTree/build/test_api.py @@ -5,6 +5,9 @@ from datetime import datetime, timedelta from django.urls import reverse +from rest_framework.test import APITestCase +from rest_framework import status + from part.models import Part from build.models import Build, BuildItem from stock.models import StockItem @@ -13,6 +16,84 @@ from InvenTree.status_codes import BuildStatus from InvenTree.api_tester import InvenTreeAPITestCase +class TestBuildAPI(APITestCase): + """ + Series of tests for the Build DRF API + - Tests for Build API + - Tests for BuildItem API + """ + + fixtures = [ + 'category', + 'part', + 'location', + 'build', + ] + + def setUp(self): + # Create a user for auth + user = get_user_model() + self.user = user.objects.create_user('testuser', 'test@testing.com', 'password') + + g = Group.objects.create(name='builders') + self.user.groups.add(g) + + for rule in g.rule_sets.all(): + if rule.name == 'build': + rule.can_change = True + rule.can_add = True + rule.can_delete = True + + rule.save() + + g.save() + + self.client.login(username='testuser', password='password') + + def test_get_build_list(self): + """ + Test that we can retrieve list of build objects + """ + + url = reverse('api-build-list') + response = self.client.get(url, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.assertEqual(len(response.data), 5) + + # Filter query by build status + response = self.client.get(url, {'status': 40}, format='json') + + self.assertEqual(len(response.data), 4) + + # Filter by "active" status + response = self.client.get(url, {'active': True}, format='json') + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]['pk'], 1) + + response = self.client.get(url, {'active': False}, format='json') + self.assertEqual(len(response.data), 4) + + # Filter by 'part' status + response = self.client.get(url, {'part': 25}, format='json') + self.assertEqual(len(response.data), 1) + + # Filter by an invalid part + response = self.client.get(url, {'part': 99999}, format='json') + self.assertEqual(len(response.data), 0) + + def test_get_build_item_list(self): + """ Test that we can retrieve list of BuildItem objects """ + url = reverse('api-build-item-list') + + response = self.client.get(url, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Test again, filtering by park ID + response = self.client.get(url, {'part': '1'}, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + class BuildAPITest(InvenTreeAPITestCase): """ Series of tests for the Build DRF API diff --git a/InvenTree/build/tests.py b/InvenTree/build/tests.py index 27b7720973..dd1fe9b45f 100644 --- a/InvenTree/build/tests.py +++ b/InvenTree/build/tests.py @@ -6,10 +6,6 @@ from django.urls import reverse from django.contrib.auth import get_user_model from django.contrib.auth.models import Group -from rest_framework.test import APITestCase -from rest_framework import status - -import json from datetime import datetime, timedelta from .models import Build @@ -112,84 +108,6 @@ class BuildTestSimple(TestCase): self.assertEqual(build.status, BuildStatus.CANCELLED) -class TestBuildAPI(APITestCase): - """ - Series of tests for the Build DRF API - - Tests for Build API - - Tests for BuildItem API - """ - - fixtures = [ - 'category', - 'part', - 'location', - 'build', - ] - - def setUp(self): - # Create a user for auth - user = get_user_model() - self.user = user.objects.create_user('testuser', 'test@testing.com', 'password') - - g = Group.objects.create(name='builders') - self.user.groups.add(g) - - for rule in g.rule_sets.all(): - if rule.name == 'build': - rule.can_change = True - rule.can_add = True - rule.can_delete = True - - rule.save() - - g.save() - - self.client.login(username='testuser', password='password') - - def test_get_build_list(self): - """ - Test that we can retrieve list of build objects - """ - - url = reverse('api-build-list') - response = self.client.get(url, format='json') - self.assertEqual(response.status_code, status.HTTP_200_OK) - - self.assertEqual(len(response.data), 5) - - # Filter query by build status - response = self.client.get(url, {'status': 40}, format='json') - - self.assertEqual(len(response.data), 4) - - # Filter by "active" status - response = self.client.get(url, {'active': True}, format='json') - self.assertEqual(len(response.data), 1) - self.assertEqual(response.data[0]['pk'], 1) - - response = self.client.get(url, {'active': False}, format='json') - self.assertEqual(len(response.data), 4) - - # Filter by 'part' status - response = self.client.get(url, {'part': 25}, format='json') - self.assertEqual(len(response.data), 1) - - # Filter by an invalid part - response = self.client.get(url, {'part': 99999}, format='json') - self.assertEqual(len(response.data), 0) - - def test_get_build_item_list(self): - """ Test that we can retrieve list of BuildItem objects """ - url = reverse('api-build-item-list') - - response = self.client.get(url, format='json') - self.assertEqual(response.status_code, status.HTTP_200_OK) - - # Test again, filtering by park ID - response = self.client.get(url, {'part': '1'}, format='json') - self.assertEqual(response.status_code, status.HTTP_200_OK) - - class TestBuildViews(TestCase): """ Tests for Build app views """ @@ -251,28 +169,3 @@ class TestBuildViews(TestCase): content = str(response.content) self.assertIn(build.title, content) - - def test_build_cancel(self): - """ Test the build cancellation form """ - - url = reverse('build-cancel', args=(1,)) - - # Test without confirmation - response = self.client.post(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - data = json.loads(response.content) - self.assertFalse(data['form_valid']) - - b = Build.objects.get(pk=1) - self.assertEqual(b.status, 10) # Build status is still PENDING - - # Test with confirmation - response = self.client.post(url, {'confirm_cancel': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - data = json.loads(response.content) - self.assertTrue(data['form_valid']) - - b = Build.objects.get(pk=1) - self.assertEqual(b.status, 30) # Build status is now CANCELLED diff --git a/InvenTree/order/test_views.py b/InvenTree/order/test_views.py index 220c1688db..3af234e36a 100644 --- a/InvenTree/order/test_views.py +++ b/InvenTree/order/test_views.py @@ -76,30 +76,3 @@ class POTests(OrderViewTestCase): # Response should be streaming-content (file download) self.assertIn('streaming_content', dir(response)) - - def test_po_issue(self): - """ Test PurchaseOrderIssue view """ - - url = reverse('po-issue', args=(1,)) - - order = PurchaseOrder.objects.get(pk=1) - self.assertEqual(order.status, PurchaseOrderStatus.PENDING) - - # Test without confirmation - response = self.client.post(url, {'confirm': 0}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - data = json.loads(response.content) - - self.assertFalse(data['form_valid']) - - # Test WITH confirmation - response = self.client.post(url, {'confirm': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - data = json.loads(response.content) - self.assertTrue(data['form_valid']) - - # Test that the order was actually placed - order = PurchaseOrder.objects.get(pk=1) - self.assertEqual(order.status, PurchaseOrderStatus.PLACED) From 88dbd5aa74c1448c79051b308ea29f8f62073696 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 4 May 2022 18:46:07 +1000 Subject: [PATCH 40/47] PEP fixes --- InvenTree/build/test_api.py | 3 +++ InvenTree/build/tests.py | 1 + InvenTree/order/test_views.py | 6 ------ 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/InvenTree/build/test_api.py b/InvenTree/build/test_api.py index 9551e3d07e..8c9d0fe7e1 100644 --- a/InvenTree/build/test_api.py +++ b/InvenTree/build/test_api.py @@ -5,6 +5,9 @@ from datetime import datetime, timedelta from django.urls import reverse +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group + from rest_framework.test import APITestCase from rest_framework import status diff --git a/InvenTree/build/tests.py b/InvenTree/build/tests.py index dd1fe9b45f..e8ec8b67ca 100644 --- a/InvenTree/build/tests.py +++ b/InvenTree/build/tests.py @@ -3,6 +3,7 @@ from __future__ import unicode_literals from django.test import TestCase from django.urls import reverse + from django.contrib.auth import get_user_model from django.contrib.auth.models import Group diff --git a/InvenTree/order/test_views.py b/InvenTree/order/test_views.py index 3af234e36a..f636d91fb9 100644 --- a/InvenTree/order/test_views.py +++ b/InvenTree/order/test_views.py @@ -8,12 +8,6 @@ from django.urls import reverse from django.contrib.auth import get_user_model from django.contrib.auth.models import Group -from InvenTree.status_codes import PurchaseOrderStatus - -from .models import PurchaseOrder - -import json - class OrderViewTestCase(TestCase): From 00dffd953be5913f3cfaf3c5fe3ae181e4bbb5c8 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 4 May 2022 12:39:12 +0200 Subject: [PATCH 41/47] add messages if company was deleted --- InvenTree/company/templates/company/manufacturer_part.html | 3 +++ InvenTree/company/templates/company/supplier_part.html | 3 +++ InvenTree/order/templates/order/order_base.html | 3 +++ InvenTree/report/templates/report/inventree_po_report.html | 2 +- 4 files changed, 10 insertions(+), 1 deletion(-) diff --git a/InvenTree/company/templates/company/manufacturer_part.html b/InvenTree/company/templates/company/manufacturer_part.html index 881e5870ca..e3bcb3dd7e 100644 --- a/InvenTree/company/templates/company/manufacturer_part.html +++ b/InvenTree/company/templates/company/manufacturer_part.html @@ -91,6 +91,9 @@ src="{% static 'img/blank_image.png' %}" {% if part.manufacturer %} {{ part.manufacturer.name }}{% include "clip.html"%} + {% else %} + {% trans "No manufacturer information available" %} + {% endif %} {% endif %} diff --git a/InvenTree/company/templates/company/supplier_part.html b/InvenTree/company/templates/company/supplier_part.html index 5471da15cc..930d8260e1 100644 --- a/InvenTree/company/templates/company/supplier_part.html +++ b/InvenTree/company/templates/company/supplier_part.html @@ -87,6 +87,9 @@ src="{% static 'img/blank_image.png' %}" {% trans "Supplier" %} {% if part.supplier %} {{ part.supplier.name }}{% include "clip.html"%} + {% else %} + {% trans "No supplier information available" %} + {% endif %} {% endif %} diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html index 7596e8da3a..5302b57e09 100644 --- a/InvenTree/order/templates/order/order_base.html +++ b/InvenTree/order/templates/order/order_base.html @@ -113,6 +113,9 @@ src="{% static 'img/blank_image.png' %}" {% if order.supplier %} {{ order.supplier.name }}{% include "clip.html"%} + {% else %} + {% trans "No suppplier information available" %} + {% endif %} {% endif %} diff --git a/InvenTree/report/templates/report/inventree_po_report.html b/InvenTree/report/templates/report/inventree_po_report.html index 427410576c..9e546fb70e 100644 --- a/InvenTree/report/templates/report/inventree_po_report.html +++ b/InvenTree/report/templates/report/inventree_po_report.html @@ -74,7 +74,7 @@ table td.expand {

{% trans "Purchase Order" %} {{ prefix }}{{ reference }}

- {% if supplier %}{{ supplier.name }}{% endif %} + {% if supplier %}{{ supplier.name }}{% endif %}{% else %}{% trans "Supplier was deleted" %}{% endif %}
{% endblock %} From 5435cd28c9de8b3a45393d6fd9f09b5ffe8d274e Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 4 May 2022 12:39:56 +0200 Subject: [PATCH 42/47] redirect to index if company was deleted --- InvenTree/company/templates/company/manufacturer_part.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/InvenTree/company/templates/company/manufacturer_part.html b/InvenTree/company/templates/company/manufacturer_part.html index e3bcb3dd7e..5a0e741c1a 100644 --- a/InvenTree/company/templates/company/manufacturer_part.html +++ b/InvenTree/company/templates/company/manufacturer_part.html @@ -338,6 +338,8 @@ $('#delete-part').click(function() { onSuccess: function() { {% if part.manufacturer %} window.location.href = "{% url 'company-detail' part.manufacturer.id %}"; + {% else%} + window.location.href = "{% url 'index' %}"; {% endif %} } }); From 19d3b03280c2064bf888a13db783a96d91acda6d Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 4 May 2022 13:10:49 +0200 Subject: [PATCH 43/47] fix double endif --- InvenTree/order/templates/order/order_base.html | 1 - 1 file changed, 1 deletion(-) diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html index 5302b57e09..b80275b1f3 100644 --- a/InvenTree/order/templates/order/order_base.html +++ b/InvenTree/order/templates/order/order_base.html @@ -116,7 +116,6 @@ src="{% static 'img/blank_image.png' %}" {% else %} {% trans "No suppplier information available" %} {% endif %} - {% endif %} {% if order.supplier_reference %} From 7a5be35f106576521d201260bb1da0f31d23a9de Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 4 May 2022 21:44:38 +1000 Subject: [PATCH 44/47] Add unit tests for new purchase order API endpoints: - PurchaseOrderCancel - PurchaseOrderComplete - PurchaseOrderIssue --- InvenTree/order/test_api.py | 92 +++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index d3e405e5fa..8b3cf87b76 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -238,6 +238,98 @@ class PurchaseOrderTest(OrderTest): }, expected_code=201 ) + + def test_po_cancel(self): + """ + Test the PurchaseOrderCancel API endpoint + """ + + po = models.PurchaseOrder.objects.get(pk=1) + + self.assertEqual(po.status, PurchaseOrderStatus.PENDING) + + url = reverse('api-po-cancel', kwargs={'pk': po.pk}) + + # Try to cancel the PO, but without reqiured permissions + self.post( + url, + {}, + expected_code=403, + ) + + self.assignRole('purchase_order.add') + + self.post( + url, + {}, + expected_code=201, + ) + + po.refresh_from_db() + + self.assertEqual(po.status, PurchaseOrderStatus.CANCELLED) + + # Try to cancel again (should fail) + self.post( + url, + {}, + expected_code=400, + ) + + def test_po_complete(self): + """ Test the PurchaseOrderComplete API endpoint """ + + po = models.PurchaseOrder.objects.get(pk=3) + + url = reverse('api-po-complete', kwargs={'pk': po.pk}) + + self.assertEqual(po.status, PurchaseOrderStatus.PLACED) + + # Try to complete the PO, without required permissions + response = self.post( + url, + {}, + expected_code=403, + ) + + self.assignRole('purchase_order.add') + + response = self.post( + url, + {}, + expected_code=201, + ) + + po.refresh_from_db() + + self.assertEqual(po.status, PurchaseOrderStatus.COMPLETE) + + + def test_po_issue(self): + """ Test the PurchaseOrderIssue API endpoint """ + + po = models.PurchaseOrder.objects.get(pk=2) + + url = reverse('api-po-issue', kwargs={'pk': po.pk}) + + # Try to issue the PO, without required permissions + response = self.post( + url, + {}, + expected_code=403, + ) + + self.assignRole('purchase_order.add') + + response = self.post( + url, + {}, + expected_code=201, + ) + + po.refresh_from_db() + + self.assertEqual(po.status, PurchaseOrderStatus.PLACED) class PurchaseOrderReceiveTest(OrderTest): From 1c0fba0fca2cec8c55fc82ba0fe8617867ccc786 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 4 May 2022 21:51:09 +1000 Subject: [PATCH 45/47] Add unit test for SalesOrderCancel API endpoint --- InvenTree/order/test_api.py | 61 +++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 33 deletions(-) diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index 8b3cf87b76..2ac7689434 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -9,7 +9,7 @@ from rest_framework import status from django.urls import reverse from InvenTree.api_tester import InvenTreeAPITestCase -from InvenTree.status_codes import PurchaseOrderStatus +from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus from part.models import Part from stock.models import StockItem @@ -238,7 +238,7 @@ class PurchaseOrderTest(OrderTest): }, expected_code=201 ) - + def test_po_cancel(self): """ Test the PurchaseOrderCancel API endpoint @@ -251,11 +251,7 @@ class PurchaseOrderTest(OrderTest): url = reverse('api-po-cancel', kwargs={'pk': po.pk}) # Try to cancel the PO, but without reqiured permissions - self.post( - url, - {}, - expected_code=403, - ) + self.post(url, {}, expected_code=403) self.assignRole('purchase_order.add') @@ -270,11 +266,7 @@ class PurchaseOrderTest(OrderTest): self.assertEqual(po.status, PurchaseOrderStatus.CANCELLED) # Try to cancel again (should fail) - self.post( - url, - {}, - expected_code=400, - ) + self.post(url, {}, expected_code=400) def test_po_complete(self): """ Test the PurchaseOrderComplete API endpoint """ @@ -286,25 +278,16 @@ class PurchaseOrderTest(OrderTest): self.assertEqual(po.status, PurchaseOrderStatus.PLACED) # Try to complete the PO, without required permissions - response = self.post( - url, - {}, - expected_code=403, - ) + self.post(url, {}, expected_code=403) self.assignRole('purchase_order.add') - response = self.post( - url, - {}, - expected_code=201, - ) + self.post(url, {}, expected_code=201) po.refresh_from_db() self.assertEqual(po.status, PurchaseOrderStatus.COMPLETE) - def test_po_issue(self): """ Test the PurchaseOrderIssue API endpoint """ @@ -313,19 +296,11 @@ class PurchaseOrderTest(OrderTest): url = reverse('api-po-issue', kwargs={'pk': po.pk}) # Try to issue the PO, without required permissions - response = self.post( - url, - {}, - expected_code=403, - ) + self.post(url, {}, expected_code=403) self.assignRole('purchase_order.add') - response = self.post( - url, - {}, - expected_code=201, - ) + self.post(url, {}, expected_code=201) po.refresh_from_db() @@ -880,6 +855,26 @@ class SalesOrderTest(OrderTest): expected_code=201 ) + def test_so_cancel(self): + """ Test API endpoint for cancelling a SalesOrder """ + + so = models.SalesOrder.objects.get(pk=1) + + self.assertEqual(so.status, SalesOrderStatus.PENDING) + + url = reverse('api-so-cancel', kwargs={'pk': so.pk}) + + # Try to cancel, without permission + self.post(url, {}, expected_code=403) + + self.assignRole('sales_order.add') + + self.post(url, {}, expected_code=201) + + so.refresh_from_db() + + self.assertEqual(so.status, SalesOrderStatus.CANCELLED) + class SalesOrderAllocateTest(OrderTest): """ From 055b9c9a463ab2baf4099d234da4d8a95bcdbc08 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 4 May 2022 13:57:35 +0200 Subject: [PATCH 46/47] remove duplicate endif --- InvenTree/company/templates/company/supplier_part.html | 1 - 1 file changed, 1 deletion(-) diff --git a/InvenTree/company/templates/company/supplier_part.html b/InvenTree/company/templates/company/supplier_part.html index 930d8260e1..f990b66898 100644 --- a/InvenTree/company/templates/company/supplier_part.html +++ b/InvenTree/company/templates/company/supplier_part.html @@ -90,7 +90,6 @@ src="{% static 'img/blank_image.png' %}" {% else %} {% trans "No supplier information available" %} {% endif %} - {% endif %} From 82541ede32efa2778c48e9bb7e3b3456021b9df7 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 4 May 2022 22:49:21 +1000 Subject: [PATCH 47/47] More unit tests - BuildOrderCancel - StockItemInstall - StockItemUninstall --- InvenTree/build/test_api.py | 17 ++++++- InvenTree/part/models.py | 15 ++---- InvenTree/part/test_bom_item.py | 2 +- InvenTree/stock/test_api.py | 83 +++++++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 12 deletions(-) diff --git a/InvenTree/build/test_api.py b/InvenTree/build/test_api.py index 8c9d0fe7e1..a54a92dda8 100644 --- a/InvenTree/build/test_api.py +++ b/InvenTree/build/test_api.py @@ -122,7 +122,7 @@ class BuildAPITest(InvenTreeAPITestCase): super().setUp() -class BuildOutputCompleteTest(BuildAPITest): +class BuildTest(BuildAPITest): """ Unit testing for the build complete API endpoint """ @@ -290,6 +290,21 @@ class BuildOutputCompleteTest(BuildAPITest): # Build should have been marked as complete self.assertTrue(self.build.is_complete) + def test_cancel(self): + """ Test that we can cancel a BuildOrder via the API """ + + bo = Build.objects.get(pk=1) + + url = reverse('api-build-cancel', kwargs={'pk': bo.pk}) + + self.assertEqual(bo.status, BuildStatus.PENDING) + + self.post(url, {}, expected_code=201) + + bo.refresh_from_db() + + self.assertEqual(bo.status, BuildStatus.CANCELLED) + class BuildAllocationTest(BuildAPITest): """ diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 46b2154c43..de2616b772 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -491,7 +491,7 @@ class Part(MPTTModel): def __str__(self): return f"{self.full_name} - {self.description}" - def get_parts_in_bom(self): + def get_parts_in_bom(self, **kwargs): """ Return a list of all parts in the BOM for this part. Takes into account substitutes, variant parts, and inherited BOM items @@ -499,27 +499,22 @@ class Part(MPTTModel): parts = set() - for bom_item in self.get_bom_items(): + for bom_item in self.get_bom_items(**kwargs): for part in bom_item.get_valid_parts_for_allocation(): parts.add(part) return parts - def check_if_part_in_bom(self, other_part): + def check_if_part_in_bom(self, other_part, **kwargs): """ - Check if the other_part is in the BOM for this part. + Check if the other_part is in the BOM for *this* part. Note: - Accounts for substitute parts - Accounts for variant BOMs """ - for bom_item in self.get_bom_items(): - if other_part in bom_item.get_valid_parts_for_allocation(): - return True - - # No matches found - return False + return other_part in self.get_parts_in_bom(**kwargs) def check_add_to_bom(self, parent, raise_error=False, recursive=True): """ diff --git a/InvenTree/part/test_bom_item.py b/InvenTree/part/test_bom_item.py index 88548f3cf7..0789ed08c3 100644 --- a/InvenTree/part/test_bom_item.py +++ b/InvenTree/part/test_bom_item.py @@ -43,7 +43,7 @@ class BomItemTest(TestCase): self.assertIn(self.orphan, parts) - # TODO: Tests for multi-level BOMs + self.assertTrue(self.bob.check_if_part_in_bom(self.orphan)) def test_used_in(self): self.assertEqual(self.bob.used_in_count, 1) diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index 7f94c6dedf..1f040b008d 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -29,6 +29,7 @@ class StockAPITestCase(InvenTreeAPITestCase): fixtures = [ 'category', 'part', + 'bom', 'company', 'location', 'supplier_part', @@ -643,6 +644,88 @@ class StockItemTest(StockAPITestCase): data = self.get(url).data self.assertEqual(data['purchase_price_currency'], 'NZD') + def test_install(self): + """ Test that stock item can be installed into antoher item, via the API """ + + # Select the "parent" stock item + parent_part = part.models.Part.objects.get(pk=100) + + item = StockItem.objects.create( + part=parent_part, + serial='12345688-1230', + quantity=1, + ) + + sub_part = part.models.Part.objects.get(pk=50) + sub_item = StockItem.objects.create( + part=sub_part, + serial='xyz-123', + quantity=1, + ) + + n_entries = sub_item.tracking_info.count() + + self.assertIsNone(sub_item.belongs_to) + + url = reverse('api-stock-item-install', kwargs={'pk': item.pk}) + + # Try to install an item that is *not* in the BOM for this part! + response = self.post( + url, + { + 'stock_item': 520, + 'note': 'This should fail, as Item #522 is not in the BOM', + }, + expected_code=400 + ) + + self.assertIn('Selected part is not in the Bill of Materials', str(response.data)) + + # Now, try to install an item which *is* in the BOM for the parent part + response = self.post( + url, + { + 'stock_item': sub_item.pk, + 'note': "This time, it should be good!", + }, + expected_code=201, + ) + + sub_item.refresh_from_db() + + self.assertEqual(sub_item.belongs_to, item) + + self.assertEqual(n_entries + 1, sub_item.tracking_info.count()) + + # Try to install again - this time, should fail because the StockItem is not available! + response = self.post( + url, + { + 'stock_item': sub_item.pk, + 'note': 'Expectation: failure!', + }, + expected_code=400, + ) + + self.assertIn('Stock item is unavailable', str(response.data)) + + # Now, try to uninstall via the API + + url = reverse('api-stock-item-uninstall', kwargs={'pk': sub_item.pk}) + + self.post( + url, + { + 'location': 1, + }, + expected_code=201, + ) + + sub_item.refresh_from_db() + + self.assertIsNone(sub_item.belongs_to) + self.assertEqual(sub_item.location.pk, 1) + class StocktakeTest(StockAPITestCase): """