mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-11-03 22:55:43 +00:00 
			
		
		
		
	Merge branch 'master' into devOps
This commit is contained in:
		
							
								
								
									
										1
									
								
								.github/workflows/qc_checks.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/workflows/qc_checks.yaml
									
									
									
									
										vendored
									
									
								
							@@ -153,6 +153,7 @@ jobs:
 | 
				
			|||||||
          invoke delete-data -f
 | 
					          invoke delete-data -f
 | 
				
			||||||
          invoke import-fixtures
 | 
					          invoke import-fixtures
 | 
				
			||||||
          invoke server -a 127.0.0.1:12345 &
 | 
					          invoke server -a 127.0.0.1:12345 &
 | 
				
			||||||
 | 
					          invoke wait
 | 
				
			||||||
      - name: Run Tests
 | 
					      - name: Run Tests
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          cd ${{ env.wrapper_name }}
 | 
					          cd ${{ env.wrapper_name }}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,6 +2,11 @@
 | 
				
			|||||||
Helper functions for performing API unit tests
 | 
					Helper functions for performing API unit tests
 | 
				
			||||||
"""
 | 
					"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import csv
 | 
				
			||||||
 | 
					import io
 | 
				
			||||||
 | 
					import re
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.http.response import StreamingHttpResponse
 | 
				
			||||||
from django.contrib.auth import get_user_model
 | 
					from django.contrib.auth import get_user_model
 | 
				
			||||||
from django.contrib.auth.models import Group
 | 
					from django.contrib.auth.models import Group
 | 
				
			||||||
from rest_framework.test import APITestCase
 | 
					from rest_framework.test import APITestCase
 | 
				
			||||||
@@ -165,3 +170,87 @@ class InvenTreeAPITestCase(APITestCase):
 | 
				
			|||||||
            self.assertEqual(response.status_code, expected_code)
 | 
					            self.assertEqual(response.status_code, expected_code)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return response
 | 
					        return response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def download_file(self, url, data, expected_code=None, expected_fn=None, decode=True):
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Download a file from the server, and return an in-memory file
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.get(url, data=data, format='json')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if expected_code is not None:
 | 
				
			||||||
 | 
					            self.assertEqual(response.status_code, expected_code)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Check that the response is of the correct type
 | 
				
			||||||
 | 
					        if not isinstance(response, StreamingHttpResponse):
 | 
				
			||||||
 | 
					            raise ValueError("Response is not a StreamingHttpResponse object as expected")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Extract filename
 | 
				
			||||||
 | 
					        disposition = response.headers['Content-Disposition']
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        result = re.search(r'attachment; filename="([\w.]+)"', disposition)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        fn = result.groups()[0]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if expected_fn is not None:
 | 
				
			||||||
 | 
					            self.assertEqual(expected_fn, fn)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if decode:
 | 
				
			||||||
 | 
					            # Decode data and return as StringIO file object
 | 
				
			||||||
 | 
					            fo = io.StringIO()
 | 
				
			||||||
 | 
					            fo.name = fo
 | 
				
			||||||
 | 
					            fo.write(response.getvalue().decode('UTF-8'))
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            # Return a a BytesIO file object
 | 
				
			||||||
 | 
					            fo = io.BytesIO()
 | 
				
			||||||
 | 
					            fo.name = fn
 | 
				
			||||||
 | 
					            fo.write(response.getvalue())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        fo.seek(0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return fo
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def process_csv(self, fo, delimiter=',', required_cols=None, excluded_cols=None, required_rows=None):
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Helper function to process and validate a downloaded csv file
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Check that the correct object type has been passed
 | 
				
			||||||
 | 
					        self.assertTrue(isinstance(fo, io.StringIO))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        fo.seek(0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        reader = csv.reader(fo, delimiter=delimiter)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        headers = []
 | 
				
			||||||
 | 
					        rows = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        for idx, row in enumerate(reader):
 | 
				
			||||||
 | 
					            if idx == 0:
 | 
				
			||||||
 | 
					                headers = row
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                rows.append(row)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if required_cols is not None:
 | 
				
			||||||
 | 
					            for col in required_cols:
 | 
				
			||||||
 | 
					                self.assertIn(col, headers)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if excluded_cols is not None:
 | 
				
			||||||
 | 
					            for col in excluded_cols:
 | 
				
			||||||
 | 
					                self.assertNotIn(col, headers)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if required_rows is not None:
 | 
				
			||||||
 | 
					            self.assertEqual(len(rows), required_rows)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Return the file data as a list of dict items, based on the headers
 | 
				
			||||||
 | 
					        data = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        for row in rows:
 | 
				
			||||||
 | 
					            entry = {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            for idx, col in enumerate(headers):
 | 
				
			||||||
 | 
					                entry[col] = row[idx]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            data.append(entry)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return data
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -39,7 +39,8 @@ def canAppAccessDatabase(allow_test=False):
 | 
				
			|||||||
        'createsuperuser',
 | 
					        'createsuperuser',
 | 
				
			||||||
        'wait_for_db',
 | 
					        'wait_for_db',
 | 
				
			||||||
        'prerender',
 | 
					        'prerender',
 | 
				
			||||||
        'rebuild',
 | 
					        'rebuild_models',
 | 
				
			||||||
 | 
					        'rebuild_thumbnails',
 | 
				
			||||||
        'collectstatic',
 | 
					        'collectstatic',
 | 
				
			||||||
        'makemessages',
 | 
					        'makemessages',
 | 
				
			||||||
        'compilemessages',
 | 
					        'compilemessages',
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,6 @@
 | 
				
			|||||||
import json
 | 
					import json
 | 
				
			||||||
import os
 | 
					import os
 | 
				
			||||||
 | 
					import time
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from unittest import mock
 | 
					from unittest import mock
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -406,11 +407,23 @@ class CurrencyTests(TestCase):
 | 
				
			|||||||
        with self.assertRaises(MissingRate):
 | 
					        with self.assertRaises(MissingRate):
 | 
				
			||||||
            convert_money(Money(100, 'AUD'), 'USD')
 | 
					            convert_money(Money(100, 'AUD'), 'USD')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        InvenTree.tasks.update_exchange_rates()
 | 
					        update_successful = False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        rates = Rate.objects.all()
 | 
					        # Note: the update sometimes fails in CI, let's give it a few chances
 | 
				
			||||||
 | 
					        for idx in range(10):
 | 
				
			||||||
 | 
					            InvenTree.tasks.update_exchange_rates()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.assertEqual(rates.count(), len(currency_codes()))
 | 
					            rates = Rate.objects.all()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if rates.count() == len(currency_codes()):
 | 
				
			||||||
 | 
					                update_successful = True
 | 
				
			||||||
 | 
					                break
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                print("Exchange rate update failed - retrying")
 | 
				
			||||||
 | 
					                time.sleep(1)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.assertTrue(update_successful)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Now that we have some exchange rate information, we can perform conversions
 | 
					        # Now that we have some exchange rate information, we can perform conversions
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -16,7 +16,7 @@ class BuildResource(ModelResource):
 | 
				
			|||||||
    # but we don't for other ones.
 | 
					    # but we don't for other ones.
 | 
				
			||||||
    # TODO: 2022-05-12 - Need to investigate why this is the case!
 | 
					    # TODO: 2022-05-12 - Need to investigate why this is the case!
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    pk = Field(attribute='pk')
 | 
					    id = Field(attribute='pk')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    reference = Field(attribute='reference')
 | 
					    reference = Field(attribute='reference')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -45,6 +45,7 @@ class BuildResource(ModelResource):
 | 
				
			|||||||
        clean_model_instances = True
 | 
					        clean_model_instances = True
 | 
				
			||||||
        exclude = [
 | 
					        exclude = [
 | 
				
			||||||
            'lft', 'rght', 'tree_id', 'level',
 | 
					            'lft', 'rght', 'tree_id', 'level',
 | 
				
			||||||
 | 
					            'metadata',
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -511,6 +511,50 @@ class BuildTest(BuildAPITest):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        self.assertIn('This build output has already been completed', str(response.data))
 | 
					        self.assertIn('This build output has already been completed', str(response.data))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_download_build_orders(self):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        required_cols = [
 | 
				
			||||||
 | 
					            'reference',
 | 
				
			||||||
 | 
					            'status',
 | 
				
			||||||
 | 
					            'completed',
 | 
				
			||||||
 | 
					            'batch',
 | 
				
			||||||
 | 
					            'notes',
 | 
				
			||||||
 | 
					            'title',
 | 
				
			||||||
 | 
					            'part',
 | 
				
			||||||
 | 
					            'part_name',
 | 
				
			||||||
 | 
					            'id',
 | 
				
			||||||
 | 
					            'quantity',
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        excluded_cols = [
 | 
				
			||||||
 | 
					            'lft', 'rght', 'tree_id', 'level',
 | 
				
			||||||
 | 
					            'metadata',
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.download_file(
 | 
				
			||||||
 | 
					            reverse('api-build-list'),
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                'export': 'csv',
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        ) as fo:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            data = self.process_csv(
 | 
				
			||||||
 | 
					                fo,
 | 
				
			||||||
 | 
					                required_cols=required_cols,
 | 
				
			||||||
 | 
					                excluded_cols=excluded_cols,
 | 
				
			||||||
 | 
					                required_rows=Build.objects.count()
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            for row in data:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                build = Build.objects.get(pk=row['id'])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                self.assertEqual(str(build.part.pk), row['part'])
 | 
				
			||||||
 | 
					                self.assertEqual(build.part.full_name, row['part_name'])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                self.assertEqual(build.reference, row['reference'])
 | 
				
			||||||
 | 
					                self.assertEqual(build.title, row['title'])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class BuildAllocationTest(BuildAPITest):
 | 
					class BuildAllocationTest(BuildAPITest):
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1111,6 +1111,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
 | 
				
			|||||||
            'default': 'SO',
 | 
					            'default': 'SO',
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        'SALESORDER_DEFAULT_SHIPMENT': {
 | 
				
			||||||
 | 
					            'name': _('Sales Order Default Shipment'),
 | 
				
			||||||
 | 
					            'description': _('Enable creation of default shipment with sales orders'),
 | 
				
			||||||
 | 
					            'default': False,
 | 
				
			||||||
 | 
					            'validator': bool,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        'PURCHASEORDER_REFERENCE_PREFIX': {
 | 
					        'PURCHASEORDER_REFERENCE_PREFIX': {
 | 
				
			||||||
            'name': _('Purchase Order Reference Prefix'),
 | 
					            'name': _('Purchase Order Reference Prefix'),
 | 
				
			||||||
            'description': _('Prefix value for purchase order reference'),
 | 
					            'description': _('Prefix value for purchase order reference'),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -112,28 +112,61 @@ class SettingsTest(TestCase):
 | 
				
			|||||||
        self.assertIn('STOCK_OWNERSHIP_CONTROL', result)
 | 
					        self.assertIn('STOCK_OWNERSHIP_CONTROL', result)
 | 
				
			||||||
        self.assertIn('SIGNUP_GROUP', result)
 | 
					        self.assertIn('SIGNUP_GROUP', result)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_required_values(self):
 | 
					    def run_settings_check(self, key, setting):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.assertTrue(type(setting) is dict)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        name = setting.get('name', None)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.assertIsNotNone(name)
 | 
				
			||||||
 | 
					        self.assertIn('django.utils.functional.lazy', str(type(name)))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        description = setting.get('description', None)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.assertIsNotNone(description)
 | 
				
			||||||
 | 
					        self.assertIn('django.utils.functional.lazy', str(type(description)))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if key != key.upper():
 | 
				
			||||||
 | 
					            raise ValueError(f"Setting key '{key}' is not uppercase")  # pragma: no cover
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Check that only allowed keys are provided
 | 
				
			||||||
 | 
					        allowed_keys = [
 | 
				
			||||||
 | 
					            'name',
 | 
				
			||||||
 | 
					            'description',
 | 
				
			||||||
 | 
					            'default',
 | 
				
			||||||
 | 
					            'validator',
 | 
				
			||||||
 | 
					            'hidden',
 | 
				
			||||||
 | 
					            'choices',
 | 
				
			||||||
 | 
					            'units',
 | 
				
			||||||
 | 
					            'requires_restart',
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        for k in setting.keys():
 | 
				
			||||||
 | 
					            self.assertIn(k, allowed_keys)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Check default value for boolean settings
 | 
				
			||||||
 | 
					        validator = setting.get('validator', None)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if validator is bool:
 | 
				
			||||||
 | 
					            default = setting.get('default', None)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Default value *must* be supplied for boolean setting!
 | 
				
			||||||
 | 
					            self.assertIsNotNone(default)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Default value for boolean must itself be a boolean
 | 
				
			||||||
 | 
					            self.assertIn(default, [True, False])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_setting_data(self):
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        - Ensure that every global setting has a name.
 | 
					        - Ensure that every setting has a name, which is translated
 | 
				
			||||||
        - Ensure that every global setting has a description.
 | 
					        - Ensure that every setting has a description, which is translated
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        for key in InvenTreeSetting.SETTINGS.keys():
 | 
					        for key, setting in InvenTreeSetting.SETTINGS.items():
 | 
				
			||||||
 | 
					            self.run_settings_check(key, setting)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            setting = InvenTreeSetting.SETTINGS[key]
 | 
					        for key, setting in InvenTreeUserSetting.SETTINGS.items():
 | 
				
			||||||
 | 
					            self.run_settings_check(key, setting)
 | 
				
			||||||
            name = setting.get('name', None)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            if name is None:
 | 
					 | 
				
			||||||
                raise ValueError(f'Missing GLOBAL_SETTING name for {key}')  # pragma: no cover
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            description = setting.get('description', None)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            if description is None:
 | 
					 | 
				
			||||||
                raise ValueError(f'Missing GLOBAL_SETTING description for {key}')  # pragma: no cover
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            if key != key.upper():
 | 
					 | 
				
			||||||
                raise ValueError(f"SETTINGS key '{key}' is not uppercase")  # pragma: no cover
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_defaults(self):
 | 
					    def test_defaults(self):
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -94,7 +94,6 @@ src="{% static 'img/blank_image.png' %}"
 | 
				
			|||||||
            {% else %}
 | 
					            {% else %}
 | 
				
			||||||
            <em>{% trans "No manufacturer information available" %}</em>
 | 
					            <em>{% trans "No manufacturer information available" %}</em>
 | 
				
			||||||
            {% endif %}
 | 
					            {% endif %}
 | 
				
			||||||
            {% endif %}
 | 
					 | 
				
			||||||
        </td>
 | 
					        </td>
 | 
				
			||||||
    </tr>
 | 
					    </tr>
 | 
				
			||||||
    <tr>
 | 
					    <tr>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -55,3 +55,29 @@ class CompanyViewTest(CompanyViewTestBase):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        response = self.client.get(reverse('company-index'))
 | 
					        response = self.client.get(reverse('company-index'))
 | 
				
			||||||
        self.assertEqual(response.status_code, 200)
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_manufacturer_index(self):
 | 
				
			||||||
 | 
					        """ Test the manufacturer index """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.get(reverse('manufacturer-index'))
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_customer_index(self):
 | 
				
			||||||
 | 
					        """ Test the customer index """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.get(reverse('customer-index'))
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_manufacturer_part_detail_view(self):
 | 
				
			||||||
 | 
					        """ Test the manufacturer part detail view """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.get(reverse('manufacturer-part-detail', kwargs={'pk': 1}))
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					        self.assertContains(response, 'MPN123')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_supplier_part_detail_view(self):
 | 
				
			||||||
 | 
					        """ Test the supplier part detail view """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.get(reverse('supplier-part-detail', kwargs={'pk': 10}))
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					        self.assertContains(response, 'MPN456-APPEL')
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -105,6 +105,9 @@ class PurchaseOrderResource(ModelResource):
 | 
				
			|||||||
        model = PurchaseOrder
 | 
					        model = PurchaseOrder
 | 
				
			||||||
        skip_unchanged = True
 | 
					        skip_unchanged = True
 | 
				
			||||||
        clean_model_instances = True
 | 
					        clean_model_instances = True
 | 
				
			||||||
 | 
					        exclude = [
 | 
				
			||||||
 | 
					            'metadata',
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class PurchaseOrderLineItemResource(ModelResource):
 | 
					class PurchaseOrderLineItemResource(ModelResource):
 | 
				
			||||||
@@ -147,6 +150,9 @@ class SalesOrderResource(ModelResource):
 | 
				
			|||||||
        model = SalesOrder
 | 
					        model = SalesOrder
 | 
				
			||||||
        skip_unchanged = True
 | 
					        skip_unchanged = True
 | 
				
			||||||
        clean_model_instances = True
 | 
					        clean_model_instances = True
 | 
				
			||||||
 | 
					        exclude = [
 | 
				
			||||||
 | 
					            'metadata',
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class SalesOrderLineItemResource(ModelResource):
 | 
					class SalesOrderLineItemResource(ModelResource):
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -667,9 +667,9 @@ class SalesOrderList(APIDownloadMixin, generics.ListCreateAPIView):
 | 
				
			|||||||
            outstanding = str2bool(outstanding)
 | 
					            outstanding = str2bool(outstanding)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if outstanding:
 | 
					            if outstanding:
 | 
				
			||||||
                queryset = queryset.filter(status__in=models.SalesOrderStatus.OPEN)
 | 
					                queryset = queryset.filter(status__in=SalesOrderStatus.OPEN)
 | 
				
			||||||
            else:
 | 
					            else:
 | 
				
			||||||
                queryset = queryset.exclude(status__in=models.SalesOrderStatus.OPEN)
 | 
					                queryset = queryset.exclude(status__in=SalesOrderStatus.OPEN)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Filter by 'overdue' status
 | 
					        # Filter by 'overdue' status
 | 
				
			||||||
        overdue = params.get('overdue', None)
 | 
					        overdue = params.get('overdue', None)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -12,6 +12,8 @@ from decimal import Decimal
 | 
				
			|||||||
from django.db import models, transaction
 | 
					from django.db import models, transaction
 | 
				
			||||||
from django.db.models import Q, F, Sum
 | 
					from django.db.models import Q, F, Sum
 | 
				
			||||||
from django.db.models.functions import Coalesce
 | 
					from django.db.models.functions import Coalesce
 | 
				
			||||||
 | 
					from django.db.models.signals import post_save
 | 
				
			||||||
 | 
					from django.dispatch.dispatcher import receiver
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.core.validators import MinValueValidator
 | 
					from django.core.validators import MinValueValidator
 | 
				
			||||||
from django.core.exceptions import ValidationError
 | 
					from django.core.exceptions import ValidationError
 | 
				
			||||||
@@ -809,6 +811,21 @@ class SalesOrder(Order):
 | 
				
			|||||||
        return self.pending_shipments().count()
 | 
					        return self.pending_shipments().count()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@receiver(post_save, sender=SalesOrder, dispatch_uid='build_post_save_log')
 | 
				
			||||||
 | 
					def after_save_sales_order(sender, instance: SalesOrder, created: bool, **kwargs):
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    Callback function to be executed after a SalesOrder instance is saved
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    if created and getSetting('SALESORDER_DEFAULT_SHIPMENT'):
 | 
				
			||||||
 | 
					        # A new SalesOrder has just been created
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Create default shipment
 | 
				
			||||||
 | 
					        SalesOrderShipment.objects.create(
 | 
				
			||||||
 | 
					            order=instance,
 | 
				
			||||||
 | 
					            reference='1',
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class PurchaseOrderAttachment(InvenTreeAttachment):
 | 
					class PurchaseOrderAttachment(InvenTreeAttachment):
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
    Model for storing file attachments against a PurchaseOrder object
 | 
					    Model for storing file attachments against a PurchaseOrder object
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,6 +2,8 @@
 | 
				
			|||||||
Tests for the Order API
 | 
					Tests for the Order API
 | 
				
			||||||
"""
 | 
					"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import io
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from datetime import datetime, timedelta
 | 
					from datetime import datetime, timedelta
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from rest_framework import status
 | 
					from rest_framework import status
 | 
				
			||||||
@@ -323,6 +325,77 @@ class PurchaseOrderTest(OrderTest):
 | 
				
			|||||||
        self.assertEqual(order.get_metadata('yam'), 'yum')
 | 
					        self.assertEqual(order.get_metadata('yam'), 'yum')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class PurchaseOrderDownloadTest(OrderTest):
 | 
				
			||||||
 | 
					    """Unit tests for downloading PurchaseOrder data via the API endpoint"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    required_cols = [
 | 
				
			||||||
 | 
					        'id',
 | 
				
			||||||
 | 
					        'line_items',
 | 
				
			||||||
 | 
					        'description',
 | 
				
			||||||
 | 
					        'issue_date',
 | 
				
			||||||
 | 
					        'notes',
 | 
				
			||||||
 | 
					        'reference',
 | 
				
			||||||
 | 
					        'status',
 | 
				
			||||||
 | 
					        'supplier_reference',
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    excluded_cols = [
 | 
				
			||||||
 | 
					        'metadata',
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_download_wrong_format(self):
 | 
				
			||||||
 | 
					        """Incorrect format should default raise an error"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        url = reverse('api-po-list')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.assertRaises(ValueError):
 | 
				
			||||||
 | 
					            self.download_file(
 | 
				
			||||||
 | 
					                url,
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    'export': 'xyz',
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_download_csv(self):
 | 
				
			||||||
 | 
					        """Download PurchaseOrder data as .csv"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.download_file(
 | 
				
			||||||
 | 
					            reverse('api-po-list'),
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                'export': 'csv',
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            expected_code=200,
 | 
				
			||||||
 | 
					            expected_fn='InvenTree_PurchaseOrders.csv',
 | 
				
			||||||
 | 
					        ) as fo:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            data = self.process_csv(
 | 
				
			||||||
 | 
					                fo,
 | 
				
			||||||
 | 
					                required_cols=self.required_cols,
 | 
				
			||||||
 | 
					                excluded_cols=self.excluded_cols,
 | 
				
			||||||
 | 
					                required_rows=models.PurchaseOrder.objects.count()
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            for row in data:
 | 
				
			||||||
 | 
					                order = models.PurchaseOrder.objects.get(pk=row['id'])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                self.assertEqual(order.description, row['description'])
 | 
				
			||||||
 | 
					                self.assertEqual(order.reference, row['reference'])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_download_line_items(self):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.download_file(
 | 
				
			||||||
 | 
					            reverse('api-po-line-list'),
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                'export': 'xlsx',
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            decode=False,
 | 
				
			||||||
 | 
					            expected_code=200,
 | 
				
			||||||
 | 
					            expected_fn='InvenTree_PurchaseOrderItems.xlsx',
 | 
				
			||||||
 | 
					        ) as fo:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.assertTrue(isinstance(fo, io.BytesIO))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class PurchaseOrderReceiveTest(OrderTest):
 | 
					class PurchaseOrderReceiveTest(OrderTest):
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
    Unit tests for receiving items against a PurchaseOrder
 | 
					    Unit tests for receiving items against a PurchaseOrder
 | 
				
			||||||
@@ -908,6 +981,177 @@ class SalesOrderTest(OrderTest):
 | 
				
			|||||||
        self.assertEqual(order.get_metadata('xyz'), 'abc')
 | 
					        self.assertEqual(order.get_metadata('xyz'), 'abc')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class SalesOrderLineItemTest(OrderTest):
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    Tests for the SalesOrderLineItem API
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def setUp(self):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        super().setUp()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # List of salable parts
 | 
				
			||||||
 | 
					        parts = Part.objects.filter(salable=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Create a bunch of SalesOrderLineItems for each order
 | 
				
			||||||
 | 
					        for idx, so in enumerate(models.SalesOrder.objects.all()):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            for part in parts:
 | 
				
			||||||
 | 
					                models.SalesOrderLineItem.objects.create(
 | 
				
			||||||
 | 
					                    order=so,
 | 
				
			||||||
 | 
					                    part=part,
 | 
				
			||||||
 | 
					                    quantity=(idx + 1) * 5,
 | 
				
			||||||
 | 
					                    reference=f"Order {so.reference} - line {idx}",
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.url = reverse('api-so-line-list')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_so_line_list(self):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # List *all* lines
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.get(
 | 
				
			||||||
 | 
					            self.url,
 | 
				
			||||||
 | 
					            {},
 | 
				
			||||||
 | 
					            expected_code=200,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        n = models.SalesOrderLineItem.objects.count()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # We should have received *all* lines
 | 
				
			||||||
 | 
					        self.assertEqual(len(response.data), n)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # List *all* lines, but paginate
 | 
				
			||||||
 | 
					        response = self.get(
 | 
				
			||||||
 | 
					            self.url,
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                "limit": 5,
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            expected_code=200,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.assertEqual(response.data['count'], n)
 | 
				
			||||||
 | 
					        self.assertEqual(len(response.data['results']), 5)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        n_orders = models.SalesOrder.objects.count()
 | 
				
			||||||
 | 
					        n_parts = Part.objects.filter(salable=True).count()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # List by part
 | 
				
			||||||
 | 
					        for part in Part.objects.filter(salable=True):
 | 
				
			||||||
 | 
					            response = self.get(
 | 
				
			||||||
 | 
					                self.url,
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    'part': part.pk,
 | 
				
			||||||
 | 
					                    'limit': 10,
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.assertEqual(response.data['count'], n_orders)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # List by order
 | 
				
			||||||
 | 
					        for order in models.SalesOrder.objects.all():
 | 
				
			||||||
 | 
					            response = self.get(
 | 
				
			||||||
 | 
					                self.url,
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    'order': order.pk,
 | 
				
			||||||
 | 
					                    'limit': 10,
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.assertEqual(response.data['count'], n_parts)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class SalesOrderDownloadTest(OrderTest):
 | 
				
			||||||
 | 
					    """Unit tests for downloading SalesOrder data via the API endpoint"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_download_fail(self):
 | 
				
			||||||
 | 
					        """Test that downloading without the 'export' option fails"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        url = reverse('api-so-list')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.assertRaises(ValueError):
 | 
				
			||||||
 | 
					            self.download_file(url, {}, expected_code=200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_download_xls(self):
 | 
				
			||||||
 | 
					        url = reverse('api-so-list')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Download .xls file
 | 
				
			||||||
 | 
					        with self.download_file(
 | 
				
			||||||
 | 
					            url,
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                'export': 'xls',
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            expected_code=200,
 | 
				
			||||||
 | 
					            expected_fn='InvenTree_SalesOrders.xls',
 | 
				
			||||||
 | 
					            decode=False,
 | 
				
			||||||
 | 
					        ) as fo:
 | 
				
			||||||
 | 
					            self.assertTrue(isinstance(fo, io.BytesIO))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_download_csv(self):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        url = reverse('api-so-list')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        required_cols = [
 | 
				
			||||||
 | 
					            'line_items',
 | 
				
			||||||
 | 
					            'id',
 | 
				
			||||||
 | 
					            'reference',
 | 
				
			||||||
 | 
					            'customer',
 | 
				
			||||||
 | 
					            'status',
 | 
				
			||||||
 | 
					            'shipment_date',
 | 
				
			||||||
 | 
					            'notes',
 | 
				
			||||||
 | 
					            'description',
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        excluded_cols = [
 | 
				
			||||||
 | 
					            'metadata'
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Download .xls file
 | 
				
			||||||
 | 
					        with self.download_file(
 | 
				
			||||||
 | 
					            url,
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                'export': 'csv',
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            expected_code=200,
 | 
				
			||||||
 | 
					            expected_fn='InvenTree_SalesOrders.csv',
 | 
				
			||||||
 | 
					            decode=True
 | 
				
			||||||
 | 
					        ) as fo:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            data = self.process_csv(
 | 
				
			||||||
 | 
					                fo,
 | 
				
			||||||
 | 
					                required_cols=required_cols,
 | 
				
			||||||
 | 
					                excluded_cols=excluded_cols,
 | 
				
			||||||
 | 
					                required_rows=models.SalesOrder.objects.count()
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            for line in data:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                order = models.SalesOrder.objects.get(pk=line['id'])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                self.assertEqual(line['description'], order.description)
 | 
				
			||||||
 | 
					                self.assertEqual(line['status'], str(order.status))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Download only outstanding sales orders
 | 
				
			||||||
 | 
					        with self.download_file(
 | 
				
			||||||
 | 
					            url,
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                'export': 'tsv',
 | 
				
			||||||
 | 
					                'outstanding': True,
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            expected_code=200,
 | 
				
			||||||
 | 
					            expected_fn='InvenTree_SalesOrders.tsv',
 | 
				
			||||||
 | 
					            decode=True,
 | 
				
			||||||
 | 
					        ) as fo:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.process_csv(
 | 
				
			||||||
 | 
					                fo,
 | 
				
			||||||
 | 
					                required_cols=required_cols,
 | 
				
			||||||
 | 
					                excluded_cols=excluded_cols,
 | 
				
			||||||
 | 
					                required_rows=models.SalesOrder.objects.filter(status__in=SalesOrderStatus.OPEN).count(),
 | 
				
			||||||
 | 
					                delimiter='\t',
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class SalesOrderAllocateTest(OrderTest):
 | 
					class SalesOrderAllocateTest(OrderTest):
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
    Unit tests for allocating stock items against a SalesOrder
 | 
					    Unit tests for allocating stock items against a SalesOrder
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -10,6 +10,8 @@ from company.models import Company
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from InvenTree import status_codes as status
 | 
					from InvenTree import status_codes as status
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from common.models import InvenTreeSetting
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from order.models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation, SalesOrderShipment
 | 
					from order.models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation, SalesOrderShipment
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from part.models import Part
 | 
					from part.models import Part
 | 
				
			||||||
@@ -200,3 +202,37 @@ class SalesOrderTest(TestCase):
 | 
				
			|||||||
        self.assertTrue(self.line.is_fully_allocated())
 | 
					        self.assertTrue(self.line.is_fully_allocated())
 | 
				
			||||||
        self.assertEqual(self.line.fulfilled_quantity(), 50)
 | 
					        self.assertEqual(self.line.fulfilled_quantity(), 50)
 | 
				
			||||||
        self.assertEqual(self.line.allocated_quantity(), 50)
 | 
					        self.assertEqual(self.line.allocated_quantity(), 50)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_default_shipment(self):
 | 
				
			||||||
 | 
					        # Test sales order default shipment creation
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Default setting value should be False
 | 
				
			||||||
 | 
					        self.assertEqual(False, InvenTreeSetting.get_setting('SALESORDER_DEFAULT_SHIPMENT'))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Create an order
 | 
				
			||||||
 | 
					        order_1 = SalesOrder.objects.create(
 | 
				
			||||||
 | 
					            customer=self.customer,
 | 
				
			||||||
 | 
					            reference='1235',
 | 
				
			||||||
 | 
					            customer_reference='ABC 55556'
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Order should have no shipments when setting is False
 | 
				
			||||||
 | 
					        self.assertEqual(0, order_1.shipment_count)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Update setting to True
 | 
				
			||||||
 | 
					        InvenTreeSetting.set_setting('SALESORDER_DEFAULT_SHIPMENT', True, None)
 | 
				
			||||||
 | 
					        self.assertEqual(True, InvenTreeSetting.get_setting('SALESORDER_DEFAULT_SHIPMENT'))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Create a second order
 | 
				
			||||||
 | 
					        order_2 = SalesOrder.objects.create(
 | 
				
			||||||
 | 
					            customer=self.customer,
 | 
				
			||||||
 | 
					            reference='1236',
 | 
				
			||||||
 | 
					            customer_reference='ABC 55557'
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Order should have one shipment
 | 
				
			||||||
 | 
					        self.assertEqual(1, order_2.shipment_count)
 | 
				
			||||||
 | 
					        self.assertEqual(1, order_2.pending_shipments().count())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Shipment should have default reference of '1'
 | 
				
			||||||
 | 
					        self.assertEqual('1', order_2.pending_shipments()[0].reference)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -45,6 +45,7 @@ class PartResource(ModelResource):
 | 
				
			|||||||
        exclude = [
 | 
					        exclude = [
 | 
				
			||||||
            'bom_checksum', 'bom_checked_by', 'bom_checked_date',
 | 
					            'bom_checksum', 'bom_checked_by', 'bom_checked_date',
 | 
				
			||||||
            'lft', 'rght', 'tree_id', 'level',
 | 
					            'lft', 'rght', 'tree_id', 'level',
 | 
				
			||||||
 | 
					            'metadata',
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_queryset(self):
 | 
					    def get_queryset(self):
 | 
				
			||||||
@@ -98,6 +99,7 @@ class PartCategoryResource(ModelResource):
 | 
				
			|||||||
        exclude = [
 | 
					        exclude = [
 | 
				
			||||||
            # Exclude MPTT internal model fields
 | 
					            # Exclude MPTT internal model fields
 | 
				
			||||||
            'lft', 'rght', 'tree_id', 'level',
 | 
					            'lft', 'rght', 'tree_id', 'level',
 | 
				
			||||||
 | 
					            'metadata',
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def after_import(self, dataset, result, using_transactions, dry_run, **kwargs):
 | 
					    def after_import(self, dataset, result, using_transactions, dry_run, **kwargs):
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -822,6 +822,58 @@ class PartAPITest(InvenTreeAPITestCase):
 | 
				
			|||||||
        response = self.get('/api/part/10004/', {})
 | 
					        response = self.get('/api/part/10004/', {})
 | 
				
			||||||
        self.assertEqual(response.data['variant_stock'], 500)
 | 
					        self.assertEqual(response.data['variant_stock'], 500)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_part_download(self):
 | 
				
			||||||
 | 
					        """Test download of part data via the API"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        url = reverse('api-part-list')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        required_cols = [
 | 
				
			||||||
 | 
					            'id',
 | 
				
			||||||
 | 
					            'name',
 | 
				
			||||||
 | 
					            'description',
 | 
				
			||||||
 | 
					            'in_stock',
 | 
				
			||||||
 | 
					            'category_name',
 | 
				
			||||||
 | 
					            'keywords',
 | 
				
			||||||
 | 
					            'is_template',
 | 
				
			||||||
 | 
					            'virtual',
 | 
				
			||||||
 | 
					            'trackable',
 | 
				
			||||||
 | 
					            'active',
 | 
				
			||||||
 | 
					            'notes',
 | 
				
			||||||
 | 
					            'creation_date',
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        excluded_cols = [
 | 
				
			||||||
 | 
					            'lft', 'rght', 'level', 'tree_id',
 | 
				
			||||||
 | 
					            'metadata',
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.download_file(
 | 
				
			||||||
 | 
					            url,
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                'export': 'csv',
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            expected_fn='InvenTree_Parts.csv',
 | 
				
			||||||
 | 
					        ) as fo:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            data = self.process_csv(
 | 
				
			||||||
 | 
					                fo,
 | 
				
			||||||
 | 
					                excluded_cols=excluded_cols,
 | 
				
			||||||
 | 
					                required_cols=required_cols,
 | 
				
			||||||
 | 
					                required_rows=Part.objects.count(),
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            for row in data:
 | 
				
			||||||
 | 
					                part = Part.objects.get(pk=row['id'])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                if part.IPN:
 | 
				
			||||||
 | 
					                    self.assertEqual(part.IPN, row['IPN'])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                self.assertEqual(part.name, row['name'])
 | 
				
			||||||
 | 
					                self.assertEqual(part.description, row['description'])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                if part.category:
 | 
				
			||||||
 | 
					                    self.assertEqual(part.category.name, row['category_name'])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class PartDetailTests(InvenTreeAPITestCase):
 | 
					class PartDetailTests(InvenTreeAPITestCase):
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -7,7 +7,7 @@ from django.utils.translation import gettext_lazy as _
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from maintenance_mode.core import set_maintenance_mode
 | 
					from maintenance_mode.core import set_maintenance_mode
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from InvenTree.ready import isImportingData
 | 
					from InvenTree.ready import canAppAccessDatabase
 | 
				
			||||||
from plugin import registry
 | 
					from plugin import registry
 | 
				
			||||||
from plugin.helpers import check_git_version, log_error
 | 
					from plugin.helpers import check_git_version, log_error
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -20,9 +20,8 @@ class PluginAppConfig(AppConfig):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def ready(self):
 | 
					    def ready(self):
 | 
				
			||||||
        if settings.PLUGINS_ENABLED:
 | 
					        if settings.PLUGINS_ENABLED:
 | 
				
			||||||
 | 
					            if not canAppAccessDatabase(allow_test=True):
 | 
				
			||||||
            if isImportingData():  # pragma: no cover
 | 
					                logger.info("Skipping plugin loading sequence")
 | 
				
			||||||
                logger.info('Skipping plugin loading for data import')
 | 
					 | 
				
			||||||
            else:
 | 
					            else:
 | 
				
			||||||
                logger.info('Loading InvenTree plugins')
 | 
					                logger.info('Loading InvenTree plugins')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -48,3 +47,6 @@ class PluginAppConfig(AppConfig):
 | 
				
			|||||||
            registry.git_is_modern = check_git_version()
 | 
					            registry.git_is_modern = check_git_version()
 | 
				
			||||||
            if not registry.git_is_modern:  # pragma: no cover  # simulating old git seems not worth it for coverage
 | 
					            if not registry.git_is_modern:  # pragma: no cover  # simulating old git seems not worth it for coverage
 | 
				
			||||||
                log_error(_('Your enviroment has an outdated git version. This prevents InvenTree from loading plugin details.'), 'load')
 | 
					                log_error(_('Your enviroment has an outdated git version. This prevents InvenTree from loading plugin details.'), 'load')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            logger.info("Plugins not enabled - skipping loading sequence")
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -11,7 +11,8 @@ from django.db.utils import OperationalError, ProgrammingError
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
import InvenTree.helpers
 | 
					import InvenTree.helpers
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from plugin.helpers import MixinImplementationError, MixinNotImplementedError, render_template
 | 
					from plugin.helpers import MixinImplementationError, MixinNotImplementedError
 | 
				
			||||||
 | 
					from plugin.helpers import render_template, render_text
 | 
				
			||||||
from plugin.models import PluginConfig, PluginSetting
 | 
					from plugin.models import PluginConfig, PluginSetting
 | 
				
			||||||
from plugin.registry import registry
 | 
					from plugin.registry import registry
 | 
				
			||||||
from plugin.urls import PLUGIN_BASE
 | 
					from plugin.urls import PLUGIN_BASE
 | 
				
			||||||
@@ -59,6 +60,7 @@ class SettingsMixin:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        if not plugin:
 | 
					        if not plugin:
 | 
				
			||||||
            # Cannot find associated plugin model, return
 | 
					            # Cannot find associated plugin model, return
 | 
				
			||||||
 | 
					            logger.error(f"Plugin configuration not found for plugin '{self.slug}'")
 | 
				
			||||||
            return  # pragma: no cover
 | 
					            return  # pragma: no cover
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        PluginSetting.set_setting(key, value, user, plugin=plugin)
 | 
					        PluginSetting.set_setting(key, value, user, plugin=plugin)
 | 
				
			||||||
@@ -578,10 +580,16 @@ class PanelMixin:
 | 
				
			|||||||
            if content_template:
 | 
					            if content_template:
 | 
				
			||||||
                # Render content template to HTML
 | 
					                # Render content template to HTML
 | 
				
			||||||
                panel['content'] = render_template(self, content_template, ctx)
 | 
					                panel['content'] = render_template(self, content_template, ctx)
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                # Render content string to HTML
 | 
				
			||||||
 | 
					                panel['content'] = render_text(panel.get('content', ''), ctx)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if javascript_template:
 | 
					            if javascript_template:
 | 
				
			||||||
                # Render javascript template to HTML
 | 
					                # Render javascript template to HTML
 | 
				
			||||||
                panel['javascript'] = render_template(self, javascript_template, ctx)
 | 
					                panel['javascript'] = render_template(self, javascript_template, ctx)
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                # Render javascript string to HTML
 | 
				
			||||||
 | 
					                panel['javascript'] = render_text(panel.get('javascript', ''), ctx)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            # Check for required keys
 | 
					            # Check for required keys
 | 
				
			||||||
            required_keys = ['title', 'content']
 | 
					            required_keys = ['title', 'content']
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,14 +2,19 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from django.test import TestCase
 | 
					from django.test import TestCase
 | 
				
			||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
from django.urls import include, re_path
 | 
					from django.urls import include, re_path, reverse
 | 
				
			||||||
from django.contrib.auth import get_user_model
 | 
					from django.contrib.auth import get_user_model
 | 
				
			||||||
 | 
					from django.contrib.auth.models import Group
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from error_report.models import Error
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from plugin import InvenTreePlugin
 | 
					from plugin import InvenTreePlugin
 | 
				
			||||||
from plugin.mixins import AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, APICallMixin
 | 
					from plugin.mixins import AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, APICallMixin
 | 
				
			||||||
from plugin.urls import PLUGIN_BASE
 | 
					from plugin.urls import PLUGIN_BASE
 | 
				
			||||||
from plugin.helpers import MixinNotImplementedError
 | 
					from plugin.helpers import MixinNotImplementedError
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from plugin.registry import registry
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class BaseMixinDefinition:
 | 
					class BaseMixinDefinition:
 | 
				
			||||||
    def test_mixin_name(self):
 | 
					    def test_mixin_name(self):
 | 
				
			||||||
@@ -244,3 +249,161 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
 | 
				
			|||||||
        # cover wrong token setting
 | 
					        # cover wrong token setting
 | 
				
			||||||
        with self.assertRaises(MixinNotImplementedError):
 | 
					        with self.assertRaises(MixinNotImplementedError):
 | 
				
			||||||
            self.mixin_wrong2.has_api_call()
 | 
					            self.mixin_wrong2.has_api_call()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class PanelMixinTests(TestCase):
 | 
				
			||||||
 | 
					    """Test that the PanelMixin plugin operates correctly"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    fixtures = [
 | 
				
			||||||
 | 
					        'category',
 | 
				
			||||||
 | 
					        'part',
 | 
				
			||||||
 | 
					        'location',
 | 
				
			||||||
 | 
					        'stock',
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def setUp(self):
 | 
				
			||||||
 | 
					        super().setUp()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Create a user which has all the privelages
 | 
				
			||||||
 | 
					        user = get_user_model()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.user = user.objects.create_user(
 | 
				
			||||||
 | 
					            username='username',
 | 
				
			||||||
 | 
					            email='user@email.com',
 | 
				
			||||||
 | 
					            password='password'
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Put the user into a group with the correct permissions
 | 
				
			||||||
 | 
					        group = Group.objects.create(name='mygroup')
 | 
				
			||||||
 | 
					        self.user.groups.add(group)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Give the group *all* the permissions!
 | 
				
			||||||
 | 
					        for rule in group.rule_sets.all():
 | 
				
			||||||
 | 
					            rule.can_view = True
 | 
				
			||||||
 | 
					            rule.can_change = True
 | 
				
			||||||
 | 
					            rule.can_add = True
 | 
				
			||||||
 | 
					            rule.can_delete = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            rule.save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.client.login(username='username', password='password')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_installed(self):
 | 
				
			||||||
 | 
					        """Test that the sample panel plugin is installed"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        plugins = registry.with_mixin('panel')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.assertTrue(len(plugins) > 0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.assertIn('samplepanel', [p.slug for p in plugins])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        plugins = registry.with_mixin('panel', active=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.assertEqual(len(plugins), 0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_disabled(self):
 | 
				
			||||||
 | 
					        """Test that the panels *do not load* if the plugin is not enabled"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        plugin = registry.get_plugin('samplepanel')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        plugin.set_setting('ENABLE_HELLO_WORLD', True)
 | 
				
			||||||
 | 
					        plugin.set_setting('ENABLE_BROKEN_PANEL', True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Ensure that the plugin is *not* enabled
 | 
				
			||||||
 | 
					        config = plugin.plugin_config()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.assertFalse(config.active)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Load some pages, ensure that the panel content is *not* loaded
 | 
				
			||||||
 | 
					        for url in [
 | 
				
			||||||
 | 
					            reverse('part-detail', kwargs={'pk': 1}),
 | 
				
			||||||
 | 
					            reverse('stock-item-detail', kwargs={'pk': 2}),
 | 
				
			||||||
 | 
					            reverse('stock-location-detail', kwargs={'pk': 1}),
 | 
				
			||||||
 | 
					        ]:
 | 
				
			||||||
 | 
					            response = self.client.get(
 | 
				
			||||||
 | 
					                url
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Test that these panels have *not* been loaded
 | 
				
			||||||
 | 
					            self.assertNotIn('No Content', str(response.content))
 | 
				
			||||||
 | 
					            self.assertNotIn('Hello world', str(response.content))
 | 
				
			||||||
 | 
					            self.assertNotIn('Custom Part Panel', str(response.content))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_enabled(self):
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Test that the panels *do* load if the plugin is enabled
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        plugin = registry.get_plugin('samplepanel')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.assertEqual(len(registry.with_mixin('panel', active=True)), 0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Ensure that the plugin is enabled
 | 
				
			||||||
 | 
					        config = plugin.plugin_config()
 | 
				
			||||||
 | 
					        config.active = True
 | 
				
			||||||
 | 
					        config.save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.assertTrue(config.active)
 | 
				
			||||||
 | 
					        self.assertEqual(len(registry.with_mixin('panel', active=True)), 1)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Load some pages, ensure that the panel content is *not* loaded
 | 
				
			||||||
 | 
					        urls = [
 | 
				
			||||||
 | 
					            reverse('part-detail', kwargs={'pk': 1}),
 | 
				
			||||||
 | 
					            reverse('stock-item-detail', kwargs={'pk': 2}),
 | 
				
			||||||
 | 
					            reverse('stock-location-detail', kwargs={'pk': 1}),
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        plugin.set_setting('ENABLE_HELLO_WORLD', False)
 | 
				
			||||||
 | 
					        plugin.set_setting('ENABLE_BROKEN_PANEL', False)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        for url in urls:
 | 
				
			||||||
 | 
					            response = self.client.get(url)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.assertIn('No Content', str(response.content))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # This panel is disabled by plugin setting
 | 
				
			||||||
 | 
					            self.assertNotIn('Hello world!', str(response.content))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # This panel is only active for the "Part" view
 | 
				
			||||||
 | 
					            if url == urls[0]:
 | 
				
			||||||
 | 
					                self.assertIn('Custom Part Panel', str(response.content))
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                self.assertNotIn('Custom Part Panel', str(response.content))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Enable the 'Hello World' panel
 | 
				
			||||||
 | 
					        plugin.set_setting('ENABLE_HELLO_WORLD', True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        for url in urls:
 | 
				
			||||||
 | 
					            response = self.client.get(url)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.assertIn('Hello world!', str(response.content))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # The 'Custom Part' panel should still be there, too
 | 
				
			||||||
 | 
					            if url == urls[0]:
 | 
				
			||||||
 | 
					                self.assertIn('Custom Part Panel', str(response.content))
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                self.assertNotIn('Custom Part Panel', str(response.content))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Enable the 'broken panel' setting - this will cause all panels to not render
 | 
				
			||||||
 | 
					        plugin.set_setting('ENABLE_BROKEN_PANEL', True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        n_errors = Error.objects.count()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        for url in urls:
 | 
				
			||||||
 | 
					            response = self.client.get(url)
 | 
				
			||||||
 | 
					            self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # No custom panels should have been loaded
 | 
				
			||||||
 | 
					            self.assertNotIn('No Content', str(response.content))
 | 
				
			||||||
 | 
					            self.assertNotIn('Hello world!', str(response.content))
 | 
				
			||||||
 | 
					            self.assertNotIn('Broken Panel', str(response.content))
 | 
				
			||||||
 | 
					            self.assertNotIn('Custom Part Panel', str(response.content))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Assert that each request threw an error
 | 
				
			||||||
 | 
					        self.assertEqual(Error.objects.count(), n_errors + len(urls))
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -245,4 +245,15 @@ def render_template(plugin, template_file, context=None):
 | 
				
			|||||||
    html = tmp.render(context)
 | 
					    html = tmp.render(context)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return html
 | 
					    return html
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def render_text(text, context=None):
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    Locate a raw string with provided context
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ctx = template.Context(context)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return template.Template(text).render(ctx)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# endregion
 | 
					# endregion
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -243,7 +243,7 @@ class PluginsRegistry:
 | 
				
			|||||||
    # endregion
 | 
					    # endregion
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # region registry functions
 | 
					    # region registry functions
 | 
				
			||||||
    def with_mixin(self, mixin: str):
 | 
					    def with_mixin(self, mixin: str, active=None):
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        Returns reference to all plugins that have a specified mixin enabled
 | 
					        Returns reference to all plugins that have a specified mixin enabled
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
@@ -251,6 +251,14 @@ class PluginsRegistry:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        for plugin in self.plugins.values():
 | 
					        for plugin in self.plugins.values():
 | 
				
			||||||
            if plugin.mixin_enabled(mixin):
 | 
					            if plugin.mixin_enabled(mixin):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                if active is not None:
 | 
				
			||||||
 | 
					                    # Filter by 'enabled' status
 | 
				
			||||||
 | 
					                    config = plugin.plugin_config()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    if config.active != active:
 | 
				
			||||||
 | 
					                        continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                result.append(plugin)
 | 
					                result.append(plugin)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return result
 | 
					        return result
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -12,7 +12,7 @@ class EventPluginSample(EventMixin, InvenTreePlugin):
 | 
				
			|||||||
    """
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    NAME = "EventPlugin"
 | 
					    NAME = "EventPlugin"
 | 
				
			||||||
    SLUG = "event"
 | 
					    SLUG = "sampleevent"
 | 
				
			||||||
    TITLE = "Triggered Events"
 | 
					    TITLE = "Triggered Events"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def process_event(self, event, *args, **kwargs):
 | 
					    def process_event(self, event, *args, **kwargs):
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -15,17 +15,23 @@ class CustomPanelSample(PanelMixin, SettingsMixin, InvenTreePlugin):
 | 
				
			|||||||
    """
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    NAME = "CustomPanelExample"
 | 
					    NAME = "CustomPanelExample"
 | 
				
			||||||
    SLUG = "panel"
 | 
					    SLUG = "samplepanel"
 | 
				
			||||||
    TITLE = "Custom Panel Example"
 | 
					    TITLE = "Custom Panel Example"
 | 
				
			||||||
    DESCRIPTION = "An example plugin demonstrating how custom panels can be added to the user interface"
 | 
					    DESCRIPTION = "An example plugin demonstrating how custom panels can be added to the user interface"
 | 
				
			||||||
    VERSION = "0.1"
 | 
					    VERSION = "0.1"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    SETTINGS = {
 | 
					    SETTINGS = {
 | 
				
			||||||
        'ENABLE_HELLO_WORLD': {
 | 
					        'ENABLE_HELLO_WORLD': {
 | 
				
			||||||
            'name': 'Hello World',
 | 
					            'name': 'Enable Hello World',
 | 
				
			||||||
            'description': 'Enable a custom hello world panel on every page',
 | 
					            'description': 'Enable a custom hello world panel on every page',
 | 
				
			||||||
            'default': False,
 | 
					            'default': False,
 | 
				
			||||||
            'validator': bool,
 | 
					            'validator': bool,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        'ENABLE_BROKEN_PANEL': {
 | 
				
			||||||
 | 
					            'name': 'Enable Broken Panel',
 | 
				
			||||||
 | 
					            'description': 'Enable a panel with rendering issues',
 | 
				
			||||||
 | 
					            'default': False,
 | 
				
			||||||
 | 
					            'validator': bool,
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -52,21 +58,48 @@ class CustomPanelSample(PanelMixin, SettingsMixin, InvenTreePlugin):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        panels = [
 | 
					        panels = [
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                # This panel will not be displayed, as it is missing the 'content' key
 | 
					                # Simple panel without any actual content
 | 
				
			||||||
                'title': 'No Content',
 | 
					                'title': 'No Content',
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if self.get_setting('ENABLE_HELLO_WORLD'):
 | 
					        if self.get_setting('ENABLE_HELLO_WORLD'):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # We can use template rendering in the raw content
 | 
				
			||||||
 | 
					            content = """
 | 
				
			||||||
 | 
					            <strong>Hello world!</strong>
 | 
				
			||||||
 | 
					            <hr>
 | 
				
			||||||
 | 
					            <div class='alert-alert-block alert-info'>
 | 
				
			||||||
 | 
					                <em>We can render custom content using the templating system!</em>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					            <hr>
 | 
				
			||||||
 | 
					            <table class='table table-striped'>
 | 
				
			||||||
 | 
					                <tr><td><strong>Path</strong></td><td>{{ request.path }}</tr>
 | 
				
			||||||
 | 
					                <tr><td><strong>User</strong></td><td>{{ user.username }}</tr>
 | 
				
			||||||
 | 
					            </table>
 | 
				
			||||||
 | 
					            """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            panels.append({
 | 
					            panels.append({
 | 
				
			||||||
                # This 'hello world' panel will be displayed on any view which implements custom panels
 | 
					                # This 'hello world' panel will be displayed on any view which implements custom panels
 | 
				
			||||||
                'title': 'Hello World',
 | 
					                'title': 'Hello World',
 | 
				
			||||||
                'icon': 'fas fa-boxes',
 | 
					                'icon': 'fas fa-boxes',
 | 
				
			||||||
                'content': '<b>Hello world!</b>',
 | 
					                'content': content,
 | 
				
			||||||
                'description': 'A simple panel which renders hello world',
 | 
					                'description': 'A simple panel which renders hello world',
 | 
				
			||||||
                'javascript': 'console.log("Hello world, from a custom panel!");',
 | 
					                'javascript': 'console.log("Hello world, from a custom panel!");',
 | 
				
			||||||
            })
 | 
					            })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if self.get_setting('ENABLE_BROKEN_PANEL'):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Enabling this panel will cause panel rendering to break,
 | 
				
			||||||
 | 
					            # due to the invalid tags
 | 
				
			||||||
 | 
					            panels.append({
 | 
				
			||||||
 | 
					                'title': 'Broken Panel',
 | 
				
			||||||
 | 
					                'icon': 'fas fa-times-circle',
 | 
				
			||||||
 | 
					                'content': '{% tag_not_loaded %}',
 | 
				
			||||||
 | 
					                'description': 'This panel is broken',
 | 
				
			||||||
 | 
					                'javascript': '{% another_bad_tag %}',
 | 
				
			||||||
 | 
					            })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # This panel will *only* display on the PartDetail view
 | 
					        # This panel will *only* display on the PartDetail view
 | 
				
			||||||
        if isinstance(view, PartDetail):
 | 
					        if isinstance(view, PartDetail):
 | 
				
			||||||
            panels.append({
 | 
					            panels.append({
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,3 +1,4 @@
 | 
				
			|||||||
 | 
					import logging
 | 
				
			||||||
import sys
 | 
					import sys
 | 
				
			||||||
import traceback
 | 
					import traceback
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -9,6 +10,9 @@ from error_report.models import Error
 | 
				
			|||||||
from plugin.registry import registry
 | 
					from plugin.registry import registry
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					logger = logging.getLogger('inventree')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class InvenTreePluginViewMixin:
 | 
					class InvenTreePluginViewMixin:
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
    Custom view mixin which adds context data to the view,
 | 
					    Custom view mixin which adds context data to the view,
 | 
				
			||||||
@@ -25,7 +29,7 @@ class InvenTreePluginViewMixin:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        panels = []
 | 
					        panels = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        for plug in registry.with_mixin('panel'):
 | 
					        for plug in registry.with_mixin('panel', active=True):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            try:
 | 
					            try:
 | 
				
			||||||
                panels += plug.render_panels(self, self.request, ctx)
 | 
					                panels += plug.render_panels(self, self.request, ctx)
 | 
				
			||||||
@@ -42,6 +46,8 @@ class InvenTreePluginViewMixin:
 | 
				
			|||||||
                    html=ExceptionReporter(self.request, kind, info, data).get_traceback_html(),
 | 
					                    html=ExceptionReporter(self.request, kind, info, data).get_traceback_html(),
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                logger.error(f"Plugin '{plug.slug}' could not render custom panels at '{self.request.path}'")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return panels
 | 
					        return panels
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_context_data(self, **kwargs):
 | 
					    def get_context_data(self, **kwargs):
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -31,6 +31,7 @@ class LocationResource(ModelResource):
 | 
				
			|||||||
        exclude = [
 | 
					        exclude = [
 | 
				
			||||||
            # Exclude MPTT internal model fields
 | 
					            # Exclude MPTT internal model fields
 | 
				
			||||||
            'lft', 'rght', 'tree_id', 'level',
 | 
					            'lft', 'rght', 'tree_id', 'level',
 | 
				
			||||||
 | 
					            'metadata',
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def after_import(self, dataset, result, using_transactions, dry_run, **kwargs):
 | 
					    def after_import(self, dataset, result, using_transactions, dry_run, **kwargs):
 | 
				
			||||||
@@ -119,7 +120,7 @@ class StockItemResource(ModelResource):
 | 
				
			|||||||
            # Exclude MPTT internal model fields
 | 
					            # Exclude MPTT internal model fields
 | 
				
			||||||
            'lft', 'rght', 'tree_id', 'level',
 | 
					            'lft', 'rght', 'tree_id', 'level',
 | 
				
			||||||
            # Exclude internal fields
 | 
					            # Exclude internal fields
 | 
				
			||||||
            'serial_int',
 | 
					            'serial_int', 'metadata',
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -344,6 +344,13 @@ class StockItemListTest(StockAPITestCase):
 | 
				
			|||||||
        for h in headers:
 | 
					        for h in headers:
 | 
				
			||||||
            self.assertIn(h, dataset.headers)
 | 
					            self.assertIn(h, dataset.headers)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        excluded_headers = [
 | 
				
			||||||
 | 
					            'metadata',
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        for h in excluded_headers:
 | 
				
			||||||
 | 
					            self.assertNotIn(h, dataset.headers)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Now, add a filter to the results
 | 
					        # Now, add a filter to the results
 | 
				
			||||||
        dataset = self.export_data({'location': 1})
 | 
					        dataset = self.export_data({'location': 1})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -12,6 +12,7 @@
 | 
				
			|||||||
<table class='table table-striped table-condensed'>
 | 
					<table class='table table-striped table-condensed'>
 | 
				
			||||||
    <tbody>
 | 
					    <tbody>
 | 
				
			||||||
        {% include "InvenTree/settings/setting.html" with key="SALESORDER_REFERENCE_PREFIX" %}
 | 
					        {% include "InvenTree/settings/setting.html" with key="SALESORDER_REFERENCE_PREFIX" %}
 | 
				
			||||||
 | 
					        {% include "InvenTree/settings/setting.html" with key="SALESORDER_DEFAULT_SHIPMENT" icon="fa-truck-loading" %}
 | 
				
			||||||
    </tbody>
 | 
					    </tbody>
 | 
				
			||||||
</table>
 | 
					</table>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -138,7 +138,8 @@ function completeShipment(shipment_id) {
 | 
				
			|||||||
                    $('#so-lines-table').bootstrapTable('refresh');
 | 
					                    $('#so-lines-table').bootstrapTable('refresh');
 | 
				
			||||||
                    $('#pending-shipments-table').bootstrapTable('refresh');
 | 
					                    $('#pending-shipments-table').bootstrapTable('refresh');
 | 
				
			||||||
                    $('#completed-shipments-table').bootstrapTable('refresh');
 | 
					                    $('#completed-shipments-table').bootstrapTable('refresh');
 | 
				
			||||||
                }
 | 
					                },
 | 
				
			||||||
 | 
					                reload: true
 | 
				
			||||||
            });
 | 
					            });
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -17,7 +17,7 @@ ignore =
 | 
				
			|||||||
	N812,
 | 
						N812,
 | 
				
			||||||
	# - D415 - First line should end with a period, question mark, or exclamation point
 | 
						# - D415 - First line should end with a period, question mark, or exclamation point
 | 
				
			||||||
	D415,
 | 
						D415,
 | 
				
			||||||
exclude = .git,__pycache__,*/migrations/*,*/lib/*,*/bin/*,*/media/*,*/static/*
 | 
					exclude = .git,__pycache__,*/migrations/*,*/lib/*,*/bin/*,*/media/*,*/static/*,InvenTree/plugins/*
 | 
				
			||||||
max-complexity = 20
 | 
					max-complexity = 20
 | 
				
			||||||
docstring-convention=google
 | 
					docstring-convention=google
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user