diff --git a/.github/workflows/python.yaml b/.github/workflows/python.yaml new file mode 100644 index 0000000000..7e32685af1 --- /dev/null +++ b/.github/workflows/python.yaml @@ -0,0 +1,49 @@ +# Run python library tests whenever code is pushed to master + +name: Python Bindings + +on: + push: + branches: + - master + + pull_request: + branches-ignore: + - l10* + +jobs: + + python: + runs-on: ubuntu-latest + + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + INVENTREE_DB_NAME: './test_db.sqlite' + INVENTREE_DB_ENGINE: 'sqlite3' + INVENTREE_DEBUG: info + INVENTREE_MEDIA_ROOT: ./media + INVENTREE_STATIC_ROOT: ./static + + steps: + - name: Checkout Code + uses: actions/checkout@v2 + - name: Install InvenTree + run: | + sudo apt-get update + sudo apt-get install python3-dev python3-pip python3-venv + pip3 install invoke + invoke install + invoke migrate + - name: Download Python Code + run: | + git clone --depth 1 https://github.com/inventree/inventree-python ./inventree-python + - name: Start Server + run: | + invoke import-records -f ./inventree-python/test/test_data.json + invoke server -a 127.0.0.1:8000 & + sleep 60 + - name: Run Tests + run: | + cd inventree-python + invoke test + \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5dd3580ef6..c53a837e24 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,9 @@ local_settings.py *.backup *.old +# Files used for testing +dummy_image.* + # Sphinx files docs/_build diff --git a/InvenTree/InvenTree/api_tester.py b/InvenTree/InvenTree/api_tester.py index eb92bd80c1..1cbc62ec0a 100644 --- a/InvenTree/InvenTree/api_tester.py +++ b/InvenTree/InvenTree/api_tester.py @@ -18,6 +18,7 @@ class InvenTreeAPITestCase(APITestCase): email = 'test@testing.com' superuser = False + is_staff = True auto_login = True # Set list of roles automatically associated with the user @@ -40,8 +41,12 @@ class InvenTreeAPITestCase(APITestCase): if self.superuser: self.user.is_superuser = True - self.user.save() + if self.is_staff: + self.user.is_staff = True + + self.user.save() + for role in self.roles: self.assignRole(role) @@ -73,22 +78,50 @@ class InvenTreeAPITestCase(APITestCase): ruleset.save() break - def get(self, url, data={}, code=200): + def get(self, url, data={}, expected_code=200): """ Issue a GET request """ response = self.client.get(url, data, format='json') - self.assertEqual(response.status_code, code) + if expected_code is not None: + self.assertEqual(response.status_code, expected_code) return response - def post(self, url, data): + def post(self, url, data, expected_code=None): """ Issue a POST request """ response = self.client.post(url, data=data, format='json') + if expected_code is not None: + self.assertEqual(response.status_code, expected_code) + + return response + + def delete(self, url, expected_code=None): + """ + Issue a DELETE request + """ + + response = self.client.delete(url) + + if expected_code is not None: + self.assertEqual(response.status_code, expected_code) + + return response + + def patch(self, url, data, files=None, expected_code=None): + """ + Issue a PATCH request + """ + + response = self.client.patch(url, data=data, files=files, format='json') + + if expected_code is not None: + self.assertEqual(response.status_code, expected_code) + return response diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index aeddb714a0..feb46ee667 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -4,7 +4,6 @@ import logging from django.apps import AppConfig from django.core.exceptions import AppRegistryNotReady -from django.conf import settings from InvenTree.ready import isInTestMode, canAppAccessDatabase import InvenTree.tasks @@ -66,10 +65,11 @@ class InvenTreeConfig(AppConfig): from djmoney.contrib.exchange.models import ExchangeBackend from datetime import datetime, timedelta from InvenTree.tasks import update_exchange_rates + from common.settings import currency_code_default except AppRegistryNotReady: pass - base_currency = settings.BASE_CURRENCY + base_currency = currency_code_default() update = False diff --git a/InvenTree/InvenTree/exchange.py b/InvenTree/InvenTree/exchange.py index 0695e69f48..c75a827cc7 100644 --- a/InvenTree/InvenTree/exchange.py +++ b/InvenTree/InvenTree/exchange.py @@ -1,4 +1,4 @@ -from django.conf import settings as inventree_settings +from common.settings import currency_code_default, currency_codes from djmoney.contrib.exchange.backends.base import SimpleExchangeBackend @@ -22,8 +22,8 @@ class InvenTreeExchange(SimpleExchangeBackend): return { } - def update_rates(self, base_currency=inventree_settings.BASE_CURRENCY): + def update_rates(self, base_currency=currency_code_default()): - symbols = ','.join(inventree_settings.CURRENCIES) + symbols = ','.join(currency_codes()) super().update_rates(base=base_currency, symbols=symbols) diff --git a/InvenTree/InvenTree/fields.py b/InvenTree/InvenTree/fields.py index c496c1bb22..8d9ab77463 100644 --- a/InvenTree/InvenTree/fields.py +++ b/InvenTree/InvenTree/fields.py @@ -2,6 +2,7 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +import sys from .validators import allowable_url_schemes @@ -13,8 +14,11 @@ from django.core import validators from django import forms from decimal import Decimal +from djmoney.models.fields import MoneyField as ModelMoneyField +from djmoney.forms.fields import MoneyField import InvenTree.helpers +import common.settings class InvenTreeURLFormField(FormURLField): @@ -34,6 +38,42 @@ class InvenTreeURLField(models.URLField): }) +def money_kwargs(): + """ returns the database settings for MoneyFields """ + kwargs = {} + kwargs['currency_choices'] = common.settings.currency_code_mappings() + kwargs['default_currency'] = common.settings.currency_code_default + return kwargs + + +class InvenTreeModelMoneyField(ModelMoneyField): + """ custom MoneyField for clean migrations while using dynamic currency settings """ + def __init__(self, **kwargs): + # detect if creating migration + if 'makemigrations' in sys.argv: + # remove currency information for a clean migration + kwargs['default_currency'] = '' + kwargs['currency_choices'] = [] + else: + # set defaults + kwargs.update(money_kwargs()) + + super().__init__(**kwargs) + + def formfield(self, **kwargs): + """ override form class to use own function """ + kwargs['form_class'] = InvenTreeMoneyField + return super().formfield(**kwargs) + + +class InvenTreeMoneyField(MoneyField): + """ custom MoneyField for clean migrations while using dynamic currency settings """ + def __init__(self, *args, **kwargs): + # override initial values with the real info from database + kwargs.update(money_kwargs()) + super().__init__(*args, **kwargs) + + class DatePickerFormField(forms.DateField): """ Custom date-picker field diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index 9d00697230..330bd2bb68 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -21,6 +21,9 @@ import InvenTree.version from common.models import InvenTreeSetting from .settings import MEDIA_URL, STATIC_URL +from common.settings import currency_code_default + +from djmoney.money import Money def getSetting(key, backup_value=None): @@ -247,6 +250,22 @@ def decimal2string(d): return s.rstrip("0").rstrip(".") +def decimal2money(d, currency=None): + """ + Format a Decimal number as Money + + Args: + d: A python Decimal object + currency: Currency of the input amount, defaults to default currency in settings + + Returns: + A Money object from the input(s) + """ + if not currency: + currency = currency_code_default() + return Money(d, currency) + + def WrapWithQuotes(text, quote='"'): """ Wrap the supplied text with quotes diff --git a/InvenTree/InvenTree/management/commands/rebuild_models.py b/InvenTree/InvenTree/management/commands/rebuild_models.py new file mode 100644 index 0000000000..2a60da9365 --- /dev/null +++ b/InvenTree/InvenTree/management/commands/rebuild_models.py @@ -0,0 +1,60 @@ +""" +Custom management command to rebuild all MPTT models + +- This is crucial after importing any fixtures, etc +""" + +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + """ + Rebuild all database models which leverage the MPTT structure. + """ + + def handle(self, *args, **kwargs): + + # Part model + try: + print("Rebuilding Part objects") + + from part.models import Part + Part.objects.rebuild() + except: + print("Error rebuilding Part objects") + + # Part category + try: + print("Rebuilding PartCategory objects") + + from part.models import PartCategory + PartCategory.objects.rebuild() + except: + print("Error rebuilding PartCategory objects") + + # StockItem model + try: + print("Rebuilding StockItem objects") + + from stock.models import StockItem + StockItem.objects.rebuild() + except: + print("Error rebuilding StockItem objects") + + # StockLocation model + try: + print("Rebuilding StockLocation objects") + + from stock.models import StockLocation + StockLocation.objects.rebuild() + except: + print("Error rebuilding StockLocation objects") + + # Build model + try: + print("Rebuilding Build objects") + + from build.models import Build + Build.objects.rebuild() + except: + print("Error rebuilding Build objects") diff --git a/InvenTree/InvenTree/ready.py b/InvenTree/InvenTree/ready.py index 5a4f1e9576..7d63861f4b 100644 --- a/InvenTree/InvenTree/ready.py +++ b/InvenTree/InvenTree/ready.py @@ -12,7 +12,7 @@ def isInTestMode(): return False -def canAppAccessDatabase(): +def canAppAccessDatabase(allow_test=False): """ Returns True if the apps.py file can access database records. @@ -26,19 +26,23 @@ def canAppAccessDatabase(): 'flush', 'loaddata', 'dumpdata', - 'makemirations', + 'makemigrations', 'migrate', 'check', - 'mediarestore', 'shell', 'createsuperuser', 'wait_for_db', 'prerender', + 'rebuild', 'collectstatic', 'makemessages', 'compilemessages', ] + if not allow_test: + # Override for testing mode? + excluded_commands.append('test') + for cmd in excluded_commands: if cmd in sys.argv: return False diff --git a/InvenTree/InvenTree/serializers.py b/InvenTree/InvenTree/serializers.py index fa7674723c..772daa06ab 100644 --- a/InvenTree/InvenTree/serializers.py +++ b/InvenTree/InvenTree/serializers.py @@ -2,16 +2,19 @@ Serializers used in various InvenTree apps """ - # -*- coding: utf-8 -*- from __future__ import unicode_literals -from rest_framework import serializers - import os from django.conf import settings from django.contrib.auth.models import User +from django.core.exceptions import ValidationError as DjangoValidationError + +from rest_framework import serializers +from rest_framework.utils import model_meta +from rest_framework.fields import empty +from rest_framework.exceptions import ValidationError class UserSerializer(serializers.ModelSerializer): @@ -39,18 +42,103 @@ class InvenTreeModelSerializer(serializers.ModelSerializer): but also ensures that the underlying model class data are checked on validation. """ - def validate(self, data): - """ Perform serializer validation. + def __init__(self, instance=None, data=empty, **kwargs): + + # self.instance = instance + + # If instance is None, we are creating a new instance + if instance is None and data is not empty: + + # Required to side-step immutability of a QueryDict + data = data.copy() + + # Add missing fields which have default values + ModelClass = self.Meta.model + + fields = model_meta.get_field_info(ModelClass) + + for field_name, field in fields.fields.items(): + + """ + Update the field IF (and ONLY IF): + - The field has a specified default value + - The field does not already have a value set + """ + if field.has_default() and field_name not in data: + + value = field.default + + # Account for callable functions + if callable(value): + try: + value = value() + except: + continue + + data[field_name] = value + + super().__init__(instance, data, **kwargs) + + def get_initial(self): + """ + Construct initial data for the serializer. + Use the 'default' values specified by the django model definition + """ + + initials = super().get_initial().copy() + + # Are we creating a new instance? + if self.instance is None: + ModelClass = self.Meta.model + + fields = model_meta.get_field_info(ModelClass) + + for field_name, field in fields.fields.items(): + + if field.has_default() and field_name not in initials: + + value = field.default + + # Account for callable functions + if callable(value): + try: + value = value() + except: + continue + + initials[field_name] = value + + return initials + + def run_validation(self, data=empty): + """ + Perform serializer validation. In addition to running validators on the serializer fields, this class ensures that the underlying model is also validated. """ - # Run any native validation checks first (may throw an ValidationError) - data = super(serializers.ModelSerializer, self).validate(data) + # Run any native validation checks first (may raise a ValidationError) + data = super().run_validation(data) # Now ensure the underlying model is correct - instance = self.Meta.model(**data) - instance.clean() + + if not hasattr(self, 'instance') or self.instance is None: + # No instance exists (we are creating a new one) + instance = self.Meta.model(**data) + else: + # Instance already exists (we are updating!) + instance = self.instance + + # Update instance fields + for attr, value in data.items(): + setattr(instance, attr, value) + + # Run a 'full_clean' on the model. + # Note that by default, DRF does *not* perform full model validation! + try: + instance.full_clean() + except (ValidationError, DjangoValidationError) as exc: + raise ValidationError(detail=serializers.as_serializer_error(exc)) return data @@ -82,3 +170,17 @@ class InvenTreeAttachmentSerializerField(serializers.FileField): return None return os.path.join(str(settings.MEDIA_URL), str(value)) + + +class InvenTreeImageSerializerField(serializers.ImageField): + """ + Custom image serializer. + On upload, validate that the file is a valid image file + """ + + def to_representation(self, value): + + if not value: + return None + + return os.path.join(str(settings.MEDIA_URL), str(value)) diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 208472ff2e..6c1d52487d 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -23,6 +23,7 @@ import moneyed import yaml from django.utils.translation import gettext_lazy as _ +from django.contrib.messages import constants as messages def _is_true(x): @@ -97,7 +98,7 @@ DOCKER = _is_true(get_setting( # Configure logging settings log_level = get_setting( 'INVENTREE_LOG_LEVEL', - CONFIG.get('log_level', 'DEBUG') + CONFIG.get('log_level', 'WARNING') ) logging.basicConfig( @@ -521,10 +522,6 @@ for currency in CURRENCIES: print(f"Currency code '{currency}' is not supported") sys.exit(1) -BASE_CURRENCY = get_setting( - 'INVENTREE_BASE_CURRENCY', - CONFIG.get('base_currency', 'USD') -) # Custom currency exchange backend EXCHANGE_BACKEND = 'InvenTree.exchange.InvenTreeExchange' @@ -611,3 +608,9 @@ IMPORT_EXPORT_USE_TRANSACTIONS = True INTERNAL_IPS = [ '127.0.0.1', ] + +MESSAGE_TAGS = { + messages.SUCCESS: 'alert alert-block alert-success', + messages.ERROR: 'alert alert-block alert-danger', + messages.INFO: 'alert alert-block alert-info', +} diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index eed6c6ad21..e4d12576fa 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -718,7 +718,7 @@ position:relative; height: auto !important; max-height: calc(100vh - 200px) !important; - overflow-y: scroll; + overflow-y: auto; padding: 10px; } diff --git a/InvenTree/InvenTree/static/script/inventree/api.js b/InvenTree/InvenTree/static/script/inventree/api.js index 52aba80ef5..b43bcc8419 100644 --- a/InvenTree/InvenTree/static/script/inventree/api.js +++ b/InvenTree/InvenTree/static/script/inventree/api.js @@ -58,7 +58,7 @@ function inventreeFormDataUpload(url, data, options={}) { xhr.setRequestHeader('X-CSRFToken', csrftoken); }, url: url, - method: 'POST', + method: options.method || 'POST', data: data, processData: false, contentType: false, diff --git a/InvenTree/InvenTree/static/script/inventree/inventree.js b/InvenTree/InvenTree/static/script/inventree/inventree.js index 1a6fdec47a..79e02d7da5 100644 --- a/InvenTree/InvenTree/static/script/inventree/inventree.js +++ b/InvenTree/InvenTree/static/script/inventree/inventree.js @@ -219,6 +219,7 @@ function enableDragAndDrop(element, url, options) { data - Other form data to upload success - Callback function in case of success error - Callback function in case of error + method - HTTP method */ data = options.data || {}; @@ -254,7 +255,8 @@ function enableDragAndDrop(element, url, options) { if (options.error) { options.error(xhr, status, error); } - } + }, + method: options.method || 'POST', } ); } else { diff --git a/InvenTree/InvenTree/status.py b/InvenTree/InvenTree/status.py index 970e88831d..512c68e93b 100644 --- a/InvenTree/InvenTree/status.py +++ b/InvenTree/InvenTree/status.py @@ -4,9 +4,10 @@ Provides system status functionality checks. # -*- coding: utf-8 -*- from django.utils.translation import ugettext_lazy as _ +from django.utils import timezone import logging -from datetime import datetime, timedelta +from datetime import timedelta from django_q.models import Success from django_q.monitor import Stat @@ -34,7 +35,7 @@ def is_worker_running(**kwargs): Check to see if we have a result within the last 20 minutes """ - now = datetime.now() + now = timezone.now() past = now - timedelta(minutes=20) results = Success.objects.filter( @@ -60,21 +61,21 @@ def is_email_configured(): # Display warning unless in test mode if not settings.TESTING: - logger.warning("EMAIL_HOST is not configured") + logger.debug("EMAIL_HOST is not configured") if not settings.EMAIL_HOST_USER: configured = False # Display warning unless in test mode if not settings.TESTING: - logger.warning("EMAIL_HOST_USER is not configured") + logger.debug("EMAIL_HOST_USER is not configured") if not settings.EMAIL_HOST_PASSWORD: configured = False # Display warning unless in test mode if not settings.TESTING: - logger.warning("EMAIL_HOST_PASSWORD is not configured") + logger.debug("EMAIL_HOST_PASSWORD is not configured") return configured diff --git a/InvenTree/InvenTree/tasks.py b/InvenTree/InvenTree/tasks.py index d45df99152..cab87ee64b 100644 --- a/InvenTree/InvenTree/tasks.py +++ b/InvenTree/InvenTree/tasks.py @@ -28,7 +28,7 @@ def schedule_task(taskname, **kwargs): try: from django_q.models import Schedule except (AppRegistryNotReady): - logger.warning("Could not start background tasks - App registry not ready") + logger.info("Could not start background tasks - App registry not ready") return try: @@ -80,7 +80,7 @@ def heartbeat(): try: from django_q.models import Success - logger.warning("Could not perform heartbeat task - App registry not ready") + logger.info("Could not perform heartbeat task - App registry not ready") except AppRegistryNotReady: return @@ -105,7 +105,7 @@ def delete_successful_tasks(): try: from django_q.models import Success except AppRegistryNotReady: - logger.warning("Could not perform 'delete_successful_tasks' - App registry not ready") + logger.info("Could not perform 'delete_successful_tasks' - App registry not ready") return threshold = datetime.now() - timedelta(days=30) @@ -126,6 +126,7 @@ def check_for_updates(): import common.models except AppRegistryNotReady: # Apps not yet loaded! + logger.info("Could not perform 'check_for_updates' - App registry not ready") return response = requests.get('https://api.github.com/repos/inventree/inventree/releases/latest') @@ -169,9 +170,10 @@ def update_exchange_rates(): try: from InvenTree.exchange import InvenTreeExchange from djmoney.contrib.exchange.models import ExchangeBackend, Rate - from django.conf import settings + from common.settings import currency_code_default, currency_codes except AppRegistryNotReady: # Apps not yet loaded! + logger.info("Could not perform 'update_exchange_rates' - App registry not ready") return except: # Other error? @@ -190,14 +192,14 @@ def update_exchange_rates(): backend = InvenTreeExchange() print(f"Updating exchange rates from {backend.url}") - base = settings.BASE_CURRENCY + base = currency_code_default() print(f"Using base currency '{base}'") backend.update_rates(base_currency=base) # Remove any exchange rates which are not in the provided currencies - Rate.objects.filter(backend="InvenTreeExchange").exclude(currency__in=settings.CURRENCIES).delete() + Rate.objects.filter(backend="InvenTreeExchange").exclude(currency__in=currency_codes()).delete() def send_email(subject, body, recipients, from_email=None): diff --git a/InvenTree/InvenTree/test_api.py b/InvenTree/InvenTree/test_api.py index 8435d756fb..18f9319624 100644 --- a/InvenTree/InvenTree/test_api.py +++ b/InvenTree/InvenTree/test_api.py @@ -2,6 +2,11 @@ from rest_framework import status +from django.test import TestCase + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group + from django.urls import reverse from InvenTree.api_tester import InvenTreeAPITestCase @@ -11,6 +16,87 @@ from users.models import RuleSet from base64 import b64encode +class HTMLAPITests(TestCase): + """ + Test that we can access the REST API endpoints via the HTML interface. + + History: Discovered on 2021-06-28 a bug in InvenTreeModelSerializer, + which raised an AssertionError when using the HTML API interface, + while the regular JSON interface continued to work as expected. + """ + + def setUp(self): + super().setUp() + + # Create a user + 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_part_api(self): + url = reverse('api-part-list') + + # Check JSON response + response = self.client.get(url, HTTP_ACCEPT='application/json') + self.assertEqual(response.status_code, 200) + + # Check HTTP response + response = self.client.get(url, HTTP_ACCEPT='text/html') + self.assertEqual(response.status_code, 200) + + def test_build_api(self): + url = reverse('api-build-list') + + # Check JSON response + response = self.client.get(url, HTTP_ACCEPT='application/json') + self.assertEqual(response.status_code, 200) + + # Check HTTP response + response = self.client.get(url, HTTP_ACCEPT='text/html') + self.assertEqual(response.status_code, 200) + + def test_stock_api(self): + url = reverse('api-stock-list') + + # Check JSON response + response = self.client.get(url, HTTP_ACCEPT='application/json') + self.assertEqual(response.status_code, 200) + + # Check HTTP response + response = self.client.get(url, HTTP_ACCEPT='text/html') + self.assertEqual(response.status_code, 200) + + def test_company_list(self): + url = reverse('api-company-list') + + # Check JSON response + response = self.client.get(url, HTTP_ACCEPT='application/json') + self.assertEqual(response.status_code, 200) + + # Check HTTP response + response = self.client.get(url, HTTP_ACCEPT='text/html') + self.assertEqual(response.status_code, 200) + + class APITests(InvenTreeAPITestCase): """ Tests for the InvenTree API """ @@ -77,7 +163,7 @@ class APITests(InvenTreeAPITestCase): self.assertIn('version', data) self.assertIn('instance', data) - self.assertEquals('InvenTree', data['server']) + self.assertEqual('InvenTree', data['server']) def test_role_view(self): """ diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index b7e5b98c1b..150662cb8e 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -5,8 +5,6 @@ from django.test import TestCase import django.core.exceptions as django_exceptions from django.core.exceptions import ValidationError -from django.conf import settings - from djmoney.money import Money from djmoney.contrib.exchange.models import Rate, convert_money from djmoney.contrib.exchange.exceptions import MissingRate @@ -22,6 +20,7 @@ from decimal import Decimal import InvenTree.tasks from stock.models import StockLocation +from common.settings import currency_codes class ValidatorTest(TestCase): @@ -337,13 +336,11 @@ class CurrencyTests(TestCase): with self.assertRaises(MissingRate): convert_money(Money(100, 'AUD'), 'USD') - currencies = settings.CURRENCIES - InvenTree.tasks.update_exchange_rates() rates = Rate.objects.all() - self.assertEqual(rates.count(), len(currencies)) + self.assertEqual(rates.count(), len(currency_codes())) # Now that we have some exchange rate information, we can perform conversions diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index 5161bee6a1..6afa5ebadd 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -8,21 +8,27 @@ import re import common.models -INVENTREE_SW_VERSION = "0.2.4 pre" +INVENTREE_SW_VERSION = "0.2.5 pre" + +INVENTREE_API_VERSION = 6 """ Increment thi API version number whenever there is a significant change to the API that any clients need to know about -v3 -> 2021-05-22: - - The updated StockItem "history tracking" now uses a different interface +v6 -> 2021-06-23 + - Part and Company images can now be directly uploaded via the REST API + +v5 -> 2021-06-21 + - Adds API interface for manufacturer part parameters v4 -> 2021-06-01 - BOM items can now accept "variant stock" to be assigned against them - Many slight API tweaks were needed to get this to work properly! -""" +v3 -> 2021-05-22: + - The updated StockItem "history tracking" now uses a different interface -INVENTREE_API_VERSION = 4 +""" def inventreeInstanceName(): diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py index 06aec54c18..4b559642ca 100644 --- a/InvenTree/InvenTree/views.py +++ b/InvenTree/InvenTree/views.py @@ -12,7 +12,6 @@ from django.utils.translation import gettext_lazy as _ from django.template.loader import render_to_string from django.http import HttpResponse, JsonResponse, HttpResponseRedirect from django.urls import reverse_lazy -from django.conf import settings from django.contrib.auth.mixins import PermissionRequiredMixin @@ -21,6 +20,7 @@ from django.views.generic import ListView, DetailView, CreateView, FormView, Del from django.views.generic.base import RedirectView, TemplateView from djmoney.contrib.exchange.models import ExchangeBackend, Rate +from common.settings import currency_code_default, currency_codes from part.models import Part, PartCategory from stock.models import StockLocation, StockItem @@ -337,7 +337,7 @@ class AjaxMixin(InvenTreeRoleMixin): # Do nothing by default pass - def renderJsonResponse(self, request, form=None, data={}, context=None): + def renderJsonResponse(self, request, form=None, data=None, context=None): """ Render a JSON response based on specific class context. Args: @@ -349,6 +349,9 @@ class AjaxMixin(InvenTreeRoleMixin): Returns: JSON response object """ + # a empty dict as default can be dangerous - set it here if empty + if not data: + data = {} if not request.is_ajax(): return HttpResponseRedirect('/') @@ -817,8 +820,8 @@ class CurrencySettingsView(TemplateView): ctx = super().get_context_data(**kwargs).copy() ctx['settings'] = InvenTreeSetting.objects.all().order_by('key') - ctx["base_currency"] = settings.BASE_CURRENCY - ctx["currencies"] = settings.CURRENCIES + ctx["base_currency"] = currency_code_default() + ctx["currencies"] = currency_codes ctx["rates"] = Rate.objects.filter(backend="InvenTreeExchange") diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py index 160642281a..1cb973fe05 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -165,6 +165,19 @@ class BuildItemList(generics.ListCreateAPIView): serializer_class = BuildItemSerializer + def get_serializer(self, *args, **kwargs): + + try: + params = self.request.query_params + + kwargs['part_detail'] = str2bool(params.get('part_detail', False)) + kwargs['build_detail'] = str2bool(params.get('build_detail', False)) + kwargs['location_detail'] = str2bool(params.get('location_detail', False)) + except AttributeError: + pass + + return self.serializer_class(*args, **kwargs) + def get_queryset(self): """ Override the queryset method, to allow filtering by stock_item.part diff --git a/InvenTree/build/migrations/0029_auto_20210601_1525.py b/InvenTree/build/migrations/0029_auto_20210601_1525.py index c5ea04b5c9..e8b2d58947 100644 --- a/InvenTree/build/migrations/0029_auto_20210601_1525.py +++ b/InvenTree/build/migrations/0029_auto_20210601_1525.py @@ -40,7 +40,8 @@ def assign_bom_items(apps, schema_editor): except BomItem.DoesNotExist: pass - print(f"Assigned BomItem for {count_valid}/{count_total} entries") + if count_total > 0: + print(f"Assigned BomItem for {count_valid}/{count_total} entries") def unassign_bom_items(apps, schema_editor): diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index 629422f6e5..d8573cfa70 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -13,7 +13,8 @@ from rest_framework import serializers from InvenTree.serializers import InvenTreeModelSerializer from stock.serializers import StockItemSerializerBrief -from part.serializers import PartBriefSerializer +from stock.serializers import LocationSerializer +from part.serializers import PartSerializer, PartBriefSerializer from .models import Build, BuildItem @@ -99,22 +100,45 @@ class BuildItemSerializer(InvenTreeModelSerializer): bom_part = serializers.IntegerField(source='bom_item.sub_part.pk', read_only=True) part = serializers.IntegerField(source='stock_item.part.pk', read_only=True) - part_name = serializers.CharField(source='stock_item.part.full_name', read_only=True) - part_thumb = serializers.CharField(source='getStockItemThumbnail', read_only=True) + location = serializers.IntegerField(source='stock_item.location.pk', read_only=True) + + # Extra (optional) detail fields + part_detail = PartSerializer(source='stock_item.part', many=False, read_only=True) + build_detail = BuildSerializer(source='build', many=False, read_only=True) stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True) + location_detail = LocationSerializer(source='stock_item.location', read_only=True) quantity = serializers.FloatField() + def __init__(self, *args, **kwargs): + + build_detail = kwargs.pop('build_detail', False) + part_detail = kwargs.pop('part_detail', False) + location_detail = kwargs.pop('location_detail', False) + + super().__init__(*args, **kwargs) + + if not build_detail: + self.fields.pop('build_detail') + + if not part_detail: + self.fields.pop('part_detail') + + if not location_detail: + self.fields.pop('location_detail') + class Meta: model = BuildItem fields = [ 'pk', 'bom_part', 'build', + 'build_detail', 'install_into', + 'location', + 'location_detail', 'part', - 'part_name', - 'part_thumb', + 'part_detail', 'stock_item', 'stock_item_detail', 'quantity' diff --git a/InvenTree/common/files.py b/InvenTree/common/files.py index 377120f44d..f805ceaace 100644 --- a/InvenTree/common/files.py +++ b/InvenTree/common/files.py @@ -22,17 +22,19 @@ class FileManager: # Fields which are used for item matching (only one of them is needed) ITEM_MATCH_HEADERS = [] - + # Fields which would be helpful but are not required OPTIONAL_HEADERS = [] + OPTIONAL_MATCH_HEADERS = [] + EDITABLE_HEADERS = [] HEADERS = [] def __init__(self, file, name=None): """ Initialize the FileManager class with a user-uploaded file object """ - + # Set name if name: self.name = name @@ -71,47 +73,34 @@ class FileManager: raise ValidationError(_('Error reading file (incorrect dimension)')) except KeyError: raise ValidationError(_('Error reading file (data could be corrupted)')) - + return cleaned_data def process(self, file): """ Process file """ self.data = self.__class__.validate(file) - + def update_headers(self): """ Update headers """ - self.HEADERS = self.REQUIRED_HEADERS + self.ITEM_MATCH_HEADERS + self.OPTIONAL_HEADERS - + self.HEADERS = self.REQUIRED_HEADERS + self.ITEM_MATCH_HEADERS + self.OPTIONAL_MATCH_HEADERS + self.OPTIONAL_HEADERS + def setup(self): - """ Setup headers depending on the file name """ + """ + Setup headers + should be overriden in usage to set the Different Headers + """ if not self.name: return - if self.name == 'order': - self.REQUIRED_HEADERS = [ - 'Quantity', - ] - - self.ITEM_MATCH_HEADERS = [ - 'Manufacturer_MPN', - 'Supplier_SKU', - ] - - self.OPTIONAL_HEADERS = [ - 'Purchase_Price', - 'Reference', - 'Notes', - ] - - # Update headers - self.update_headers() + # Update headers + self.update_headers() def guess_header(self, header, threshold=80): """ Try to match a header (from the file) to a list of known headers - + Args: header - Header name to look for threshold - Match threshold for fuzzy search @@ -145,7 +134,7 @@ class FileManager: return matches[0]['header'] return None - + def columns(self): """ Return a list of headers for the thingy """ headers = [] diff --git a/InvenTree/common/forms.py b/InvenTree/common/forms.py index f161c8cc01..4a2a1601aa 100644 --- a/InvenTree/common/forms.py +++ b/InvenTree/common/forms.py @@ -8,12 +8,7 @@ from __future__ import unicode_literals from django import forms from django.utils.translation import gettext as _ -from djmoney.forms.fields import MoneyField - from InvenTree.forms import HelperForm -from InvenTree.helpers import clean_decimal - -from common.settings import currency_code_default from .files import FileManager from .models import InvenTreeSetting @@ -32,7 +27,7 @@ class SettingEditForm(HelperForm): ] -class UploadFile(forms.Form): +class UploadFileForm(forms.Form): """ Step 1 of FileManagementFormView """ file = forms.FileField( @@ -70,9 +65,9 @@ class UploadFile(forms.Form): return file -class MatchField(forms.Form): +class MatchFieldForm(forms.Form): """ Step 2 of FileManagementFormView """ - + def __init__(self, *args, **kwargs): # Get FileManager @@ -88,7 +83,7 @@ class MatchField(forms.Form): columns = file_manager.columns() # Get headers choices headers_choices = [(header, header) for header in file_manager.HEADERS] - + # Create column fields for col in columns: field_name = col['name'] @@ -103,9 +98,9 @@ class MatchField(forms.Form): self.fields[field_name].initial = col['guess'] -class MatchItem(forms.Form): +class MatchItemForm(forms.Form): """ Step 3 of FileManagementFormView """ - + def __init__(self, *args, **kwargs): # Get FileManager @@ -131,24 +126,41 @@ class MatchItem(forms.Form): for col in row['data']: # Get column matching col_guess = col['column'].get('guess', None) + # Set field name + field_name = col_guess.lower() + '-' + str(row['index']) + + # check if field def was overriden + overriden_field = self.get_special_field(col_guess, row, file_manager) + if overriden_field: + self.fields[field_name] = overriden_field # Create input for required headers - if col_guess in file_manager.REQUIRED_HEADERS: - # Set field name - field_name = col_guess.lower() + '-' + str(row['index']) + elif col_guess in file_manager.REQUIRED_HEADERS: + # Get value + value = row.get(col_guess.lower(), '') # Set field input box - if 'quantity' in col_guess.lower(): - self.fields[field_name] = forms.CharField( - required=False, - widget=forms.NumberInput(attrs={ - 'name': 'quantity' + str(row['index']), - 'class': 'numberinput', # form-control', - 'type': 'number', - 'min': '0', - 'step': 'any', - 'value': clean_decimal(row.get('quantity', '')), - }) - ) + self.fields[field_name] = forms.CharField( + required=True, + initial=value, + ) + + # Create item selection box + elif col_guess in file_manager.OPTIONAL_MATCH_HEADERS: + # Get item options + item_options = [(option.id, option) for option in row['match_options_' + col_guess]] + # Get item match + item_match = row['match_' + col_guess] + # Set field select box + self.fields[field_name] = forms.ChoiceField( + choices=[('', '-' * 10)] + item_options, + required=False, + widget=forms.Select(attrs={ + 'class': 'select bomselect', + }) + ) + # Update select box when match was found + if item_match: + self.fields[field_name].initial = item_match.id # Create item selection box elif col_guess in file_manager.ITEM_MATCH_HEADERS: @@ -176,22 +188,15 @@ class MatchItem(forms.Form): # Optional entries elif col_guess in file_manager.OPTIONAL_HEADERS: - # Set field name - field_name = col_guess.lower() + '-' + str(row['index']) # Get value value = row.get(col_guess.lower(), '') # Set field input box - if 'price' in col_guess.lower(): - self.fields[field_name] = MoneyField( - label=_(col_guess), - default_currency=currency_code_default(), - decimal_places=5, - max_digits=19, - required=False, - default_amount=clean_decimal(value), - ) - else: - self.fields[field_name] = forms.CharField( - required=False, - initial=value, - ) + self.fields[field_name] = forms.CharField( + required=False, + initial=value, + ) + + def get_special_field(self, col_guess, row, file_manager): + """ Function to be overriden in inherited forms to add specific form settings """ + + return None diff --git a/InvenTree/common/migrations/0010_migrate_currency_setting.py b/InvenTree/common/migrations/0010_migrate_currency_setting.py new file mode 100644 index 0000000000..23076ff200 --- /dev/null +++ b/InvenTree/common/migrations/0010_migrate_currency_setting.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.4 on 2021-07-01 15:39 + +from django.db import migrations +from common.models import InvenTreeSetting +from InvenTree.settings import get_setting, CONFIG + +def set_default_currency(apps, schema_editor): + """ migrate the currency setting from config.yml to db """ + # get value from settings-file + base_currency = get_setting('INVENTREE_BASE_CURRENCY', CONFIG.get('base_currency', 'USD')) + # write to database + InvenTreeSetting.set_setting('INVENTREE_DEFAULT_CURRENCY', base_currency, None, create=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0009_delete_currency'), + ] + + operations = [ + migrations.RunPython(set_default_currency), + ] diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index ec76475901..c8a5839f4e 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -14,11 +14,11 @@ from django.db import models, transaction from django.db.utils import IntegrityError, OperationalError from django.conf import settings -from djmoney.models.fields import MoneyField +from djmoney.settings import CURRENCY_CHOICES from djmoney.contrib.exchange.models import convert_money from djmoney.contrib.exchange.exceptions import MissingRate -from common.settings import currency_code_default +import common.settings from django.utils.translation import ugettext_lazy as _ from django.core.validators import MinValueValidator, URLValidator @@ -81,6 +81,13 @@ class InvenTreeSetting(models.Model): 'default': '', }, + 'INVENTREE_DEFAULT_CURRENCY': { + 'name': _('Default Currency'), + 'description': _('Default currency'), + 'default': 'USD', + 'choices': CURRENCY_CHOICES, + }, + 'INVENTREE_DOWNLOAD_FROM_URL': { 'name': _('Download from URL'), 'description': _('Allow download of remote images and files from external URL'), @@ -205,6 +212,41 @@ class InvenTreeSetting(models.Model): 'validator': bool, }, + 'PART_SHOW_IMPORT': { + 'name': _('Show Import in Views'), + 'description': _('Display the import wizard in some part views'), + 'default': False, + 'validator': bool, + }, + + 'PART_SHOW_PRICE_IN_FORMS': { + 'name': _('Show Price in Forms'), + 'description': _('Display part price in some forms'), + 'default': True, + 'validator': bool, + }, + + 'PART_SHOW_RELATED': { + 'name': _('Show related parts'), + 'description': _('Display related parts for a part'), + 'default': True, + 'validator': bool, + }, + + 'PART_INTERNAL_PRICE': { + 'name': _('Internal Prices'), + 'description': _('Enable internal prices for parts'), + 'default': False, + 'validator': bool + }, + + 'PART_BOM_USE_INTERNAL_PRICE': { + 'name': _('Internal Price as BOM-Price'), + 'description': _('Use the internal price (if set) in BOM-price calculations'), + 'default': False, + 'validator': bool + }, + 'REPORT_DEBUG_MODE': { 'name': _('Debug Mode'), 'description': _('Generate reports in debug mode (HTML output)'), @@ -700,10 +742,9 @@ class PriceBreak(models.Model): help_text=_('Price break quantity'), ) - price = MoneyField( + price = InvenTree.fields.InvenTreeModelMoneyField( max_digits=19, decimal_places=4, - default_currency=currency_code_default(), null=True, verbose_name=_('Price'), help_text=_('Unit price at specified quantity'), @@ -726,7 +767,7 @@ class PriceBreak(models.Model): return converted.amount -def get_price(instance, quantity, moq=True, multiples=True, currency=None): +def get_price(instance, quantity, moq=True, multiples=True, currency=None, break_name: str = 'price_breaks'): """ Calculate the price based on quantity price breaks. - Don't forget to add in flat-fee cost (base_cost field) @@ -734,7 +775,10 @@ def get_price(instance, quantity, moq=True, multiples=True, currency=None): - If order multiples are to be observed, then we need to calculate based on that, too """ - price_breaks = instance.price_breaks.all() + if hasattr(instance, break_name): + price_breaks = getattr(instance, break_name).all() + else: + price_breaks = [] # No price break information available? if len(price_breaks) == 0: @@ -753,10 +797,10 @@ def get_price(instance, quantity, moq=True, multiples=True, currency=None): if currency is None: # Default currency selection - currency = currency_code_default() + currency = common.settings.currency_code_default() pb_min = None - for pb in instance.price_breaks.all(): + for pb in price_breaks: # Store smallest price break if not pb_min: pb_min = pb diff --git a/InvenTree/common/settings.py b/InvenTree/common/settings.py index 60265f4cb9..e255ed0904 100644 --- a/InvenTree/common/settings.py +++ b/InvenTree/common/settings.py @@ -6,9 +6,9 @@ User-configurable settings for the common app from __future__ import unicode_literals from moneyed import CURRENCIES +from django.conf import settings import common.models -from django.conf import settings def currency_code_default(): @@ -16,7 +16,7 @@ def currency_code_default(): Returns the default currency code (or USD if not specified) """ - code = settings.BASE_CURRENCY + code = common.models.InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY') if code not in CURRENCIES: code = 'USD' @@ -24,6 +24,20 @@ def currency_code_default(): return code +def currency_code_mappings(): + """ + Returns the current currency choices + """ + return [(a, a) for a in settings.CURRENCIES] + + +def currency_codes(): + """ + Returns the current currency codes + """ + return [a for a in settings.CURRENCIES] + + def stock_expiry_enabled(): """ Returns True if the stock expiry feature is enabled diff --git a/InvenTree/common/views.py b/InvenTree/common/views.py index 857b6b2c51..f953dffa81 100644 --- a/InvenTree/common/views.py +++ b/InvenTree/common/views.py @@ -13,8 +13,9 @@ from django.conf import settings from django.core.files.storage import FileSystemStorage from formtools.wizard.views import SessionWizardView +from crispy_forms.helper import FormHelper -from InvenTree.views import AjaxUpdateView +from InvenTree.views import AjaxUpdateView, AjaxView from InvenTree.helpers import str2bool from . import models @@ -117,7 +118,6 @@ class MultiStepFormView(SessionWizardView): form_steps_description: description for each form """ - form_list = [] form_steps_template = [] form_steps_description = [] file_manager = None @@ -126,10 +126,10 @@ class MultiStepFormView(SessionWizardView): def __init__(self, *args, **kwargs): """ Override init method to set media folder """ - super().__init__(*args, **kwargs) + super().__init__(**kwargs) self.process_media_folder() - + def process_media_folder(self): """ Process media folder """ @@ -141,7 +141,7 @@ class MultiStepFormView(SessionWizardView): def get_template_names(self): """ Select template """ - + try: # Get template template = self.form_steps_template[self.steps.index] @@ -152,7 +152,7 @@ class MultiStepFormView(SessionWizardView): def get_context_data(self, **kwargs): """ Update context data """ - + # Retrieve current context context = super().get_context_data(**kwargs) @@ -176,9 +176,9 @@ class FileManagementFormView(MultiStepFormView): name = None form_list = [ - ('upload', forms.UploadFile), - ('fields', forms.MatchField), - ('items', forms.MatchItem), + ('upload', forms.UploadFileForm), + ('fields', forms.MatchFieldForm), + ('items', forms.MatchItemForm), ] form_steps_description = [ _("Upload File"), @@ -188,11 +188,26 @@ class FileManagementFormView(MultiStepFormView): media_folder = 'file_upload/' extra_context_data = {} - def get_context_data(self, form, **kwargs): + def __init__(self, *args, **kwargs): + """ Initialize the FormView """ + + # Perform all checks and inits for MultiStepFormView + super().__init__(self, *args, **kwargs) + + # Check for file manager class + if not hasattr(self, 'file_manager_class') and not issubclass(self.file_manager_class, FileManager): + raise NotImplementedError('A subclass of a file manager class needs to be set!') + + def get_context_data(self, form=None, **kwargs): + """ Handle context data """ + + if form is None: + form = self.get_form() + context = super().get_context_data(form=form, **kwargs) if self.steps.current in ('fields', 'items'): - + # Get columns and row data self.columns = self.file_manager.columns() self.rows = self.file_manager.rows() @@ -203,7 +218,7 @@ class FileManagementFormView(MultiStepFormView): elif self.steps.current == 'items': # Set form table data self.set_form_table_data(form=form) - + # Update context context.update({'rows': self.rows}) context.update({'columns': self.columns}) @@ -227,7 +242,7 @@ class FileManagementFormView(MultiStepFormView): # Get file file = upload_files.get('upload-file', None) if file: - self.file_manager = FileManager(file=file, name=self.name) + self.file_manager = self.file_manager_class(file=file, name=self.name) def get_form_kwargs(self, step=None): """ Update kwargs to dynamically build forms """ @@ -262,13 +277,22 @@ class FileManagementFormView(MultiStepFormView): self.get_form_table_data(data) self.set_form_table_data() self.get_field_selection() - + kwargs['row_data'] = self.rows return kwargs - + return super().get_form_kwargs() + def get_form(self, step=None, data=None, files=None): + """ add crispy-form helper to form """ + form = super().get_form(step=step, data=data, files=files) + + form.helper = FormHelper() + form.helper.form_show_labels = False + + return form + def get_form_table_data(self, form_data): """ Extract table cell data from form data and fields. These data are used to maintain state between sessions. @@ -327,7 +351,7 @@ class FileManagementFormView(MultiStepFormView): col_id = int(s[3]) except ValueError: continue - + if row_id not in self.row_data: self.row_data[row_id] = {} @@ -362,19 +386,20 @@ class FileManagementFormView(MultiStepFormView): 'name': self.column_names[idx], 'guess': self.column_selections[idx], } - + cell_data = { 'cell': item, 'idx': idx, 'column': column_data, } data.append(cell_data) - + row = { 'index': row_idx, 'data': data, 'errors': {}, } + self.rows.append(row) # In the item selection step: update row data with mapping to form fields @@ -414,6 +439,33 @@ class FileManagementFormView(MultiStepFormView): """ pass + def get_clean_items(self): + """ returns dict with all cleaned values """ + items = {} + + for form_key, form_value in self.get_all_cleaned_data().items(): + # Split key from row value + try: + (field, idx) = form_key.split('-') + except ValueError: + continue + + try: + if idx not in items: + # Insert into items + items.update({ + idx: { + self.form_field_map[field]: form_value, + } + }) + else: + # Update items + items[idx][self.form_field_map[field]] = form_value + except KeyError: + pass + + return items + def check_field_selection(self, form): """ Check field matching """ @@ -431,7 +483,7 @@ class FileManagementFormView(MultiStepFormView): if col in self.column_selections.values(): part_match_found = True break - + # If not, notify user if not part_match_found: for col in self.file_manager.ITEM_MATCH_HEADERS: @@ -451,7 +503,7 @@ class FileManagementFormView(MultiStepFormView): n = list(self.column_selections.values()).count(self.column_selections[col]) if n > 1 and self.column_selections[col] not in duplicates: duplicates.append(self.column_selections[col]) - + # Store extra context data self.extra_context_data = { 'missing_columns': missing_columns, @@ -497,3 +549,70 @@ class FileManagementFormView(MultiStepFormView): return self.render(form) return super().post(*args, **kwargs) + + +class FileManagementAjaxView(AjaxView): + """ Use a FileManagementFormView as base for a AjaxView + Inherit this class before inheriting the base FileManagementFormView + + ajax_form_steps_template: templates for rendering ajax + validate: function to validate the current form -> normally point to the same function in the base FileManagementFormView + """ + + def post(self, request): + # check if back-step button was selected + wizard_back = self.request.POST.get('act-btn_back', None) + if wizard_back: + back_step_index = self.get_step_index() - 1 + self.storage.current_step = list(self.get_form_list().keys())[back_step_index] + return self.renderJsonResponse(request, data={'form_valid': None}) + + # validate form + form = self.get_form(data=self.request.POST, files=self.request.FILES) + form_valid = self.validate(self.steps.current, form) + + # check if valid + if not form_valid: + return self.renderJsonResponse(request, data={'form_valid': None}) + + # store the cleaned data and files. + self.storage.set_step_data(self.steps.current, self.process_step(form)) + self.storage.set_step_files(self.steps.current, self.process_step_files(form)) + + # check if the current step is the last step + if self.steps.current == self.steps.last: + # call done - to process data, returned response is not used + self.render_done(form) + data = {'form_valid': True, 'success': _('Parts imported')} + return self.renderJsonResponse(request, data=data) + else: + self.storage.current_step = self.steps.next + + return self.renderJsonResponse(request, data={'form_valid': None}) + + def get(self, request): + if 'reset' in request.GET: + # reset form + self.storage.reset() + self.storage.current_step = self.steps.first + return self.renderJsonResponse(request) + + def renderJsonResponse(self, request, form=None, data={}, context=None): + """ always set the right templates before rendering """ + self.setTemplate() + return super().renderJsonResponse(request, form=form, data=data, context=context) + + def get_data(self): + data = super().get_data() + data['hideErrorMessage'] = '1' # hide the error + buttons = [{'name': 'back', 'title': _('Previous Step')}] if self.get_step_index() > 0 else [] + data['buttons'] = buttons # set buttons + return data + + def setTemplate(self): + """ set template name and title """ + self.ajax_template_name = self.ajax_form_steps_template[self.get_step_index()] + self.ajax_form_title = self.form_steps_description[self.get_step_index()] + + def validate(self, obj, form, **kwargs): + raise NotImplementedError('This function needs to be overridden!') diff --git a/InvenTree/company/admin.py b/InvenTree/company/admin.py index b339a65c0e..2c3be87c84 100644 --- a/InvenTree/company/admin.py +++ b/InvenTree/company/admin.py @@ -11,6 +11,7 @@ import import_export.widgets as widgets from .models import Company from .models import SupplierPart from .models import SupplierPriceBreak +from .models import ManufacturerPart, ManufacturerPartParameter from part.models import Part @@ -71,6 +72,92 @@ class SupplierPartAdmin(ImportExportModelAdmin): ] +class ManufacturerPartResource(ModelResource): + """ + Class for managing ManufacturerPart data import/export + """ + + part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part)) + + part_name = Field(attribute='part__full_name', readonly=True) + + manufacturer = Field(attribute='manufacturer', widget=widgets.ForeignKeyWidget(Company)) + + manufacturer_name = Field(attribute='manufacturer__name', readonly=True) + + class Meta: + model = ManufacturerPart + skip_unchanged = True + report_skipped = True + clean_model_instances = True + + +class ManufacturerPartParameterInline(admin.TabularInline): + """ + Inline for editing ManufacturerPartParameter objects, + directly from the ManufacturerPart admin view. + """ + + model = ManufacturerPartParameter + + +class SupplierPartInline(admin.TabularInline): + """ + Inline for the SupplierPart model + """ + + model = SupplierPart + + +class ManufacturerPartAdmin(ImportExportModelAdmin): + """ + Admin class for ManufacturerPart model + """ + + resource_class = ManufacturerPartResource + + list_display = ('part', 'manufacturer', 'MPN') + + search_fields = [ + 'manufacturer__name', + 'part__name', + 'MPN', + ] + + inlines = [ + SupplierPartInline, + ManufacturerPartParameterInline, + ] + + +class ManufacturerPartParameterResource(ModelResource): + """ + Class for managing ManufacturerPartParameter data import/export + """ + + class Meta: + model = ManufacturerPartParameter + skip_unchanged = True + report_skipped = True + clean_model_instance = True + + +class ManufacturerPartParameterAdmin(ImportExportModelAdmin): + """ + Admin class for ManufacturerPartParameter model + """ + + resource_class = ManufacturerPartParameterResource + + list_display = ('manufacturer_part', 'name', 'value') + + search_fields = [ + 'manufacturer_part__manufacturer__name', + 'name', + 'value' + ] + + class SupplierPriceBreakResource(ModelResource): """ Class for managing SupplierPriceBreak data import/export """ @@ -103,3 +190,6 @@ class SupplierPriceBreakAdmin(ImportExportModelAdmin): admin.site.register(Company, CompanyAdmin) admin.site.register(SupplierPart, SupplierPartAdmin) admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin) + +admin.site.register(ManufacturerPart, ManufacturerPartAdmin) +admin.site.register(ManufacturerPartParameter, ManufacturerPartParameterAdmin) diff --git a/InvenTree/company/api.py b/InvenTree/company/api.py index ff8b6d667b..6cd1e83dfa 100644 --- a/InvenTree/company/api.py +++ b/InvenTree/company/api.py @@ -15,11 +15,11 @@ from django.db.models import Q from InvenTree.helpers import str2bool from .models import Company -from .models import ManufacturerPart +from .models import ManufacturerPart, ManufacturerPartParameter from .models import SupplierPart, SupplierPriceBreak from .serializers import CompanySerializer -from .serializers import ManufacturerPartSerializer +from .serializers import ManufacturerPartSerializer, ManufacturerPartParameterSerializer from .serializers import SupplierPartSerializer, SupplierPriceBreakSerializer @@ -103,17 +103,11 @@ class ManufacturerPartList(generics.ListCreateAPIView): # Do we wish to include extra detail? try: - kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', None)) - except AttributeError: - pass + params = self.request.query_params - try: - kwargs['manufacturer_detail'] = str2bool(self.request.query_params.get('manufacturer_detail', None)) - except AttributeError: - pass - - try: - kwargs['pretty'] = str2bool(self.request.query_params.get('pretty', None)) + kwargs['part_detail'] = str2bool(params.get('part_detail', None)) + kwargs['manufacturer_detail'] = str2bool(params.get('manufacturer_detail', None)) + kwargs['pretty'] = str2bool(params.get('pretty', None)) except AttributeError: pass @@ -181,6 +175,86 @@ class ManufacturerPartDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = ManufacturerPartSerializer +class ManufacturerPartParameterList(generics.ListCreateAPIView): + """ + API endpoint for list view of ManufacturerPartParamater model. + """ + + queryset = ManufacturerPartParameter.objects.all() + serializer_class = ManufacturerPartParameterSerializer + + def get_serializer(self, *args, **kwargs): + + # Do we wish to include any extra detail? + try: + params = self.request.query_params + + optional_fields = [ + 'manufacturer_part_detail', + ] + + for key in optional_fields: + kwargs[key] = str2bool(params.get(key, None)) + + except AttributeError: + pass + + kwargs['context'] = self.get_serializer_context() + + return self.serializer_class(*args, **kwargs) + + def filter_queryset(self, queryset): + """ + Custom filtering for the queryset + """ + + queryset = super().filter_queryset(queryset) + + params = self.request.query_params + + # Filter by manufacturer? + manufacturer = params.get('manufacturer', None) + + if manufacturer is not None: + queryset = queryset.filter(manufacturer_part__manufacturer=manufacturer) + + # Filter by part? + part = params.get('part', None) + + if part is not None: + queryset = queryset.filter(manufacturer_part__part=part) + + return queryset + + filter_backends = [ + DjangoFilterBackend, + filters.SearchFilter, + filters.OrderingFilter, + ] + + filter_fields = [ + 'name', + 'value', + 'units', + 'manufacturer_part', + ] + + search_fields = [ + 'name', + 'value', + 'units', + ] + + +class ManufacturerPartParameterDetail(generics.RetrieveUpdateDestroyAPIView): + """ + API endpoint for detail view of ManufacturerPartParameter model + """ + + queryset = ManufacturerPartParameter.objects.all() + serializer_class = ManufacturerPartParameterSerializer + + class SupplierPartList(generics.ListCreateAPIView): """ API endpoint for list view of SupplierPart object @@ -252,22 +326,11 @@ class SupplierPartList(generics.ListCreateAPIView): # Do we wish to include extra detail? try: - kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', None)) - except AttributeError: - pass - - try: - kwargs['supplier_detail'] = str2bool(self.request.query_params.get('supplier_detail', None)) - except AttributeError: - pass - - try: - kwargs['manufacturer_detail'] = str2bool(self.request.query_params.get('manufacturer_detail', None)) - except AttributeError: - pass - - try: - kwargs['pretty'] = str2bool(self.request.query_params.get('pretty', None)) + params = self.request.query_params + kwargs['part_detail'] = str2bool(params.get('part_detail', None)) + kwargs['supplier_detail'] = str2bool(params.get('supplier_detail', None)) + kwargs['manufacturer_detail'] = str2bool(params.get('manufacturer_detail', None)) + kwargs['pretty'] = str2bool(params.get('pretty', None)) except AttributeError: pass @@ -333,6 +396,13 @@ class SupplierPriceBreakList(generics.ListCreateAPIView): manufacturer_part_api_urls = [ + url(r'^parameter/', include([ + url(r'^(?P\d+)/', ManufacturerPartParameterDetail.as_view(), name='api-manufacturer-part-parameter-detail'), + + # Catch anything else + url(r'^.*$', ManufacturerPartParameterList.as_view(), name='api-manufacturer-part-parameter-list'), + ])), + url(r'^(?P\d+)/?', ManufacturerPartDetail.as_view(), name='api-manufacturer-part-detail'), # Catch anything else diff --git a/InvenTree/company/apps.py b/InvenTree/company/apps.py index 4366f63434..76798c5ad4 100644 --- a/InvenTree/company/apps.py +++ b/InvenTree/company/apps.py @@ -44,8 +44,6 @@ class CompanyConfig(AppConfig): company.image.render_variations(replace=False) except FileNotFoundError: logger.warning(f"Image file '{company.image}' missing") - company.image = None - company.save() except UnidentifiedImageError: logger.warning(f"Image file '{company.image}' is invalid") except (OperationalError, ProgrammingError): diff --git a/InvenTree/company/fixtures/supplier_part.yaml b/InvenTree/company/fixtures/supplier_part.yaml index 446339d58b..02cf33e12e 100644 --- a/InvenTree/company/fixtures/supplier_part.yaml +++ b/InvenTree/company/fixtures/supplier_part.yaml @@ -52,3 +52,10 @@ part: 2 supplier: 2 SKU: 'ZERGM312' + +- model: company.supplierpart + pk: 5 + fields: + part: 4 + supplier: 2 + SKU: 'R_4K7_0603' diff --git a/InvenTree/company/forms.py b/InvenTree/company/forms.py index 6ffa94b746..564b5fab53 100644 --- a/InvenTree/company/forms.py +++ b/InvenTree/company/forms.py @@ -6,17 +6,16 @@ Django Forms for interacting with Company app from __future__ import unicode_literals from InvenTree.forms import HelperForm -from InvenTree.fields import RoundingDecimalFormField +from InvenTree.fields import InvenTreeMoneyField, RoundingDecimalFormField from django.utils.translation import ugettext_lazy as _ import django.forms import djmoney.settings -from djmoney.forms.fields import MoneyField from common.settings import currency_code_default -from .models import Company +from .models import Company, ManufacturerPartParameter from .models import ManufacturerPart from .models import SupplierPart from .models import SupplierPriceBreak @@ -105,6 +104,21 @@ class EditManufacturerPartForm(HelperForm): ] +class EditManufacturerPartParameterForm(HelperForm): + """ + Form for creating / editing a ManufacturerPartParameter object + """ + + class Meta: + model = ManufacturerPartParameter + fields = [ + 'manufacturer_part', + 'name', + 'value', + 'units', + ] + + class EditSupplierPartForm(HelperForm): """ Form for editing a SupplierPart object """ @@ -114,9 +128,8 @@ class EditSupplierPartForm(HelperForm): 'note': 'fa-pencil-alt', } - single_pricing = MoneyField( + single_pricing = InvenTreeMoneyField( label=_('Single Price'), - default_currency=currency_code_default(), help_text=_('Single quantity price'), decimal_places=4, max_digits=19, diff --git a/InvenTree/company/migrations/0026_auto_20201110_1011.py b/InvenTree/company/migrations/0026_auto_20201110_1011.py index c6be37b967..20ec7d2f6f 100644 --- a/InvenTree/company/migrations/0026_auto_20201110_1011.py +++ b/InvenTree/company/migrations/0026_auto_20201110_1011.py @@ -71,7 +71,8 @@ def migrate_currencies(apps, schema_editor): count += 1 - print(f"Updated {count} SupplierPriceBreak rows") + if count > 0: + print(f"Updated {count} SupplierPriceBreak rows") def reverse_currencies(apps, schema_editor): """ diff --git a/InvenTree/company/migrations/0038_manufacturerpartparameter.py b/InvenTree/company/migrations/0038_manufacturerpartparameter.py new file mode 100644 index 0000000000..dccfa715e8 --- /dev/null +++ b/InvenTree/company/migrations/0038_manufacturerpartparameter.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.4 on 2021-06-20 07:48 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('company', '0037_supplierpart_update_3'), + ] + + operations = [ + migrations.CreateModel( + name='ManufacturerPartParameter', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='Parameter name', max_length=500, verbose_name='Name')), + ('value', models.CharField(help_text='Parameter value', max_length=500, verbose_name='Value')), + ('units', models.CharField(blank=True, help_text='Parameter units', max_length=64, null=True, verbose_name='Units')), + ('manufacturer_part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='parameters', to='company.manufacturerpart', verbose_name='Manufacturer Part')), + ], + options={ + 'unique_together': {('manufacturer_part', 'name')}, + }, + ), + ] diff --git a/InvenTree/company/migrations/0039_auto_20210701_0509.py b/InvenTree/company/migrations/0039_auto_20210701_0509.py new file mode 100644 index 0000000000..094c7a5009 --- /dev/null +++ b/InvenTree/company/migrations/0039_auto_20210701_0509.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.4 on 2021-07-01 05:09 + +import InvenTree.fields +from django.db import migrations +import djmoney.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('company', '0038_manufacturerpartparameter'), + ] + + operations = [ + migrations.AlterField( + model_name='supplierpricebreak', + name='price', + field=InvenTree.fields.InvenTreeModelMoneyField(currency_choices=[], decimal_places=4, default_currency='', help_text='Unit price at specified quantity', max_digits=19, null=True, verbose_name='Price'), + ), + migrations.AlterField( + model_name='supplierpricebreak', + name='price_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3), + ), + ] diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index a5fac00e7d..093d545f78 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -371,6 +371,47 @@ class ManufacturerPart(models.Model): return s +class ManufacturerPartParameter(models.Model): + """ + A ManufacturerPartParameter represents a key:value parameter for a MnaufacturerPart. + + This is used to represent parmeters / properties for a particular manufacturer part. + + Each parameter is a simple string (text) value. + """ + + class Meta: + unique_together = ('manufacturer_part', 'name') + + manufacturer_part = models.ForeignKey( + ManufacturerPart, + on_delete=models.CASCADE, + related_name='parameters', + verbose_name=_('Manufacturer Part'), + ) + + name = models.CharField( + max_length=500, + blank=False, + verbose_name=_('Name'), + help_text=_('Parameter name') + ) + + value = models.CharField( + max_length=500, + blank=False, + verbose_name=_('Value'), + help_text=_('Parameter value') + ) + + units = models.CharField( + max_length=64, + blank=True, null=True, + verbose_name=_('Units'), + help_text=_('Parameter units') + ) + + class SupplierPart(models.Model): """ Represents a unique part as provided by a Supplier Each SupplierPart is identified by a SKU (Supplier Part Number) diff --git a/InvenTree/company/serializers.py b/InvenTree/company/serializers.py index 4b1019656e..1e97756987 100644 --- a/InvenTree/company/serializers.py +++ b/InvenTree/company/serializers.py @@ -6,14 +6,15 @@ from rest_framework import serializers from sql_util.utils import SubqueryCount -from .models import Company -from .models import ManufacturerPart -from .models import SupplierPart, SupplierPriceBreak - from InvenTree.serializers import InvenTreeModelSerializer +from InvenTree.serializers import InvenTreeImageSerializerField from part.serializers import PartBriefSerializer +from .models import Company +from .models import ManufacturerPart, ManufacturerPartParameter +from .models import SupplierPart, SupplierPriceBreak + class CompanyBriefSerializer(InvenTreeModelSerializer): """ Serializer for Company object (limited detail) """ @@ -52,7 +53,7 @@ class CompanySerializer(InvenTreeModelSerializer): url = serializers.CharField(source='get_absolute_url', read_only=True) - image = serializers.CharField(source='get_thumbnail_url', read_only=True) + image = InvenTreeImageSerializerField(required=False, allow_null=True) parts_supplied = serializers.IntegerField(read_only=True) parts_manufactured = serializers.IntegerField(read_only=True) @@ -124,6 +125,35 @@ class ManufacturerPartSerializer(InvenTreeModelSerializer): ] +class ManufacturerPartParameterSerializer(InvenTreeModelSerializer): + """ + Serializer for the ManufacturerPartParameter model + """ + + manufacturer_part_detail = ManufacturerPartSerializer(source='manufacturer_part', many=False, read_only=True) + + def __init__(self, *args, **kwargs): + + man_detail = kwargs.pop('manufacturer_part_detail', False) + + super(ManufacturerPartParameterSerializer, self).__init__(*args, **kwargs) + + if not man_detail: + self.fields.pop('manufacturer_part_detail') + + class Meta: + model = ManufacturerPartParameter + + fields = [ + 'pk', + 'manufacturer_part', + 'manufacturer_part_detail', + 'name', + 'value', + 'units', + ] + + class SupplierPartSerializer(InvenTreeModelSerializer): """ Serializer for SupplierPart object """ diff --git a/InvenTree/company/templates/company/company_base.html b/InvenTree/company/templates/company/company_base.html index 1eefade272..a276f5df4f 100644 --- a/InvenTree/company/templates/company/company_base.html +++ b/InvenTree/company/templates/company/company_base.html @@ -139,11 +139,17 @@ enableDragAndDrop( "#company-thumb", - "{% url 'company-image' company.id %}", + "{% url 'api-company-detail' company.id %}", { label: 'image', + method: 'PATCH', success: function(data, status, xhr) { - location.reload(); + + if (data.image) { + $('#company-image').attr('src', data.image); + } else { + location.reload(); + } } } ); diff --git a/InvenTree/company/templates/company/manufacturer_part_suppliers.html b/InvenTree/company/templates/company/manufacturer_part_suppliers.html index 647bf4750d..9f445ec215 100644 --- a/InvenTree/company/templates/company/manufacturer_part_suppliers.html +++ b/InvenTree/company/templates/company/manufacturer_part_suppliers.html @@ -7,7 +7,7 @@ {% endblock %} {% block heading %} -{% trans "Supplier Parts" %} +{% trans "Suppliers" %} {% endblock %} {% block details %} @@ -30,9 +30,44 @@ {% endblock %} +{% block post_content_panels %} + +
+
+

{% trans "Parameters" %}

+
+
+
+
+ +
+ +
+
+
+ +
+
+
+ +{% endblock %} + {% block js_ready %} {{ block.super }} +$('#parameter-create').click(function() { + launchModalForm( + "{% url 'manufacturer-part-parameter-create' %}", + { + data: { + manufacturer_part: {{ part.id }}, + } + } + ); +}); + $('#supplier-create').click(function () { launchModalForm( "{% url 'supplier-part-create' %}", @@ -84,6 +119,16 @@ loadSupplierPartTable( } ); +loadManufacturerPartParameterTable( + "#parameter-table", + "{% url 'api-manufacturer-part-parameter-list' %}", + { + params: { + manufacturer_part: {{ part.id }}, + } + } +); + linkButtonsToSelection($("#supplier-table"), ['#supplier-part-options']) {% endblock %} \ No newline at end of file diff --git a/InvenTree/company/test_api.py b/InvenTree/company/test_api.py index c43280c76c..dd42b97801 100644 --- a/InvenTree/company/test_api.py +++ b/InvenTree/company/test_api.py @@ -50,10 +50,15 @@ class CompanyTest(InvenTreeAPITestCase): self.assertEqual(response.data['name'], 'ACME') # Change the name of the company + # Note we should not have the correct permissions (yet) data = response.data data['name'] = 'ACMOO' - response = self.client.patch(url, data, format='json') - self.assertEqual(response.status_code, status.HTTP_200_OK) + response = self.client.patch(url, data, format='json', expected_code=400) + + self.assignRole('company.change') + + response = self.client.patch(url, data, format='json', expected_code=200) + self.assertEqual(response.data['name'], 'ACMOO') def test_company_search(self): @@ -119,7 +124,9 @@ class ManufacturerTest(InvenTreeAPITestCase): data = { 'MPN': 'MPN-TEST-123', } + response = self.client.patch(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['MPN'], 'MPN-TEST-123') diff --git a/InvenTree/company/tests.py b/InvenTree/company/tests.py index b1e05efe14..e4a70b077a 100644 --- a/InvenTree/company/tests.py +++ b/InvenTree/company/tests.py @@ -65,7 +65,7 @@ class CompanySimpleTest(TestCase): self.assertEqual(acme.supplied_part_count, 4) self.assertTrue(appel.has_parts) - self.assertEqual(appel.supplied_part_count, 3) + self.assertEqual(appel.supplied_part_count, 4) self.assertTrue(zerg.has_parts) self.assertEqual(zerg.supplied_part_count, 2) diff --git a/InvenTree/company/urls.py b/InvenTree/company/urls.py index 11f1be5339..51aa81f1c7 100644 --- a/InvenTree/company/urls.py +++ b/InvenTree/company/urls.py @@ -53,20 +53,25 @@ price_break_urls = [ url(r'^(?P\d+)/delete/', views.PriceBreakDelete.as_view(), name='price-break-delete'), ] -manufacturer_part_detail_urls = [ - url(r'^edit/?', views.ManufacturerPartEdit.as_view(), name='manufacturer-part-edit'), - - url(r'^suppliers/', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part_suppliers.html'), name='manufacturer-part-suppliers'), - - url('^.*$', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part_suppliers.html'), name='manufacturer-part-detail'), -] - manufacturer_part_urls = [ url(r'^new/?', views.ManufacturerPartCreate.as_view(), name='manufacturer-part-create'), - url(r'delete/', views.ManufacturerPartDelete.as_view(), name='manufacturer-part-delete'), + url(r'^delete/', views.ManufacturerPartDelete.as_view(), name='manufacturer-part-delete'), - url(r'^(?P\d+)/', include(manufacturer_part_detail_urls)), + # URLs for ManufacturerPartParameter views (create / edit / delete) + url(r'^parameter/', include([ + url(r'^new/', views.ManufacturerPartParameterCreate.as_view(), name='manufacturer-part-parameter-create'), + url(r'^(?P\d)/', include([ + url(r'^edit/', views.ManufacturerPartParameterEdit.as_view(), name='manufacturer-part-parameter-edit'), + url(r'^delete/', views.ManufacturerPartParameterDelete.as_view(), name='manufacturer-part-parameter-delete'), + ])), + ])), + + url(r'^(?P\d+)/', include([ + url(r'^edit/?', views.ManufacturerPartEdit.as_view(), name='manufacturer-part-edit'), + url(r'^suppliers/', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part_suppliers.html'), name='manufacturer-part-suppliers'), + url('^.*$', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part_suppliers.html'), name='manufacturer-part-detail'), + ])), ] supplier_part_detail_urls = [ diff --git a/InvenTree/company/views.py b/InvenTree/company/views.py index 6de1439823..74a583710a 100644 --- a/InvenTree/company/views.py +++ b/InvenTree/company/views.py @@ -23,14 +23,14 @@ from InvenTree.views import AjaxCreateView, AjaxUpdateView, AjaxDeleteView from InvenTree.helpers import str2bool from InvenTree.views import InvenTreeRoleMixin -from .models import Company +from .models import Company, ManufacturerPartParameter from .models import ManufacturerPart from .models import SupplierPart from .models import SupplierPriceBreak from part.models import Part -from .forms import EditCompanyForm +from .forms import EditCompanyForm, EditManufacturerPartParameterForm from .forms import CompanyImageForm from .forms import EditManufacturerPartForm from .forms import EditSupplierPartForm @@ -504,6 +504,66 @@ class ManufacturerPartDelete(AjaxDeleteView): return self.renderJsonResponse(self.request, data=data, form=self.get_form()) +class ManufacturerPartParameterCreate(AjaxCreateView): + """ + View for creating a new ManufacturerPartParameter object + """ + + model = ManufacturerPartParameter + form_class = EditManufacturerPartParameterForm + ajax_form_title = _('Add Manufacturer Part Parameter') + + def get_form(self): + + form = super().get_form() + + # Hide the manufacturer_part field if specified + if form.initial.get('manufacturer_part', None): + form.fields['manufacturer_part'].widget = HiddenInput() + + return form + + def get_initial(self): + + initials = super().get_initial().copy() + + manufacturer_part = self.get_param('manufacturer_part') + + if manufacturer_part: + try: + initials['manufacturer_part'] = ManufacturerPartParameter.objects.get(pk=manufacturer_part) + except (ValueError, ManufacturerPartParameter.DoesNotExist): + pass + + return initials + + +class ManufacturerPartParameterEdit(AjaxUpdateView): + """ + View for editing a ManufacturerPartParameter object + """ + + model = ManufacturerPartParameter + form_class = EditManufacturerPartParameterForm + ajax_form_title = _('Edit Manufacturer Part Parameter') + + def get_form(self): + + form = super().get_form() + + form.fields['manufacturer_part'].widget = HiddenInput() + + return form + + +class ManufacturerPartParameterDelete(AjaxDeleteView): + """ + View for deleting a ManufacturerPartParameter object + """ + + model = ManufacturerPartParameter + + class SupplierPartDetail(DetailView): """ Detail view for SupplierPart """ model = SupplierPart @@ -563,7 +623,8 @@ class SupplierPartEdit(AjaxUpdateView): supplier_part = self.get_object() if supplier_part.manufacturer_part: - initials['manufacturer'] = supplier_part.manufacturer_part.manufacturer.id + if supplier_part.manufacturer_part.manufacturer: + initials['manufacturer'] = supplier_part.manufacturer_part.manufacturer.id initials['MPN'] = supplier_part.manufacturer_part.MPN return initials diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index 7eda59eacb..c22d76a52e 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -22,9 +22,10 @@ from .models import PurchaseOrder, PurchaseOrderLineItem from .models import PurchaseOrderAttachment from .serializers import POSerializer, POLineItemSerializer, POAttachmentSerializer -from .models import SalesOrder, SalesOrderLineItem +from .models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation from .models import SalesOrderAttachment from .serializers import SalesOrderSerializer, SOLineItemSerializer, SOAttachmentSerializer +from .serializers import SalesOrderAllocationSerializer class POList(generics.ListCreateAPIView): @@ -156,7 +157,7 @@ class POList(generics.ListCreateAPIView): ordering = '-creation_date' -class PODetail(generics.RetrieveUpdateAPIView): +class PODetail(generics.RetrieveUpdateDestroyAPIView): """ API endpoint for detail view of a PurchaseOrder object """ queryset = PurchaseOrder.objects.all() @@ -381,7 +382,7 @@ class SOList(generics.ListCreateAPIView): ordering = '-creation_date' -class SODetail(generics.RetrieveUpdateAPIView): +class SODetail(generics.RetrieveUpdateDestroyAPIView): """ API endpoint for detail view of a SalesOrder object. """ @@ -422,17 +423,11 @@ class SOLineItemList(generics.ListCreateAPIView): def get_serializer(self, *args, **kwargs): try: - kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', False)) - except AttributeError: - pass + params = self.request.query_params - try: - kwargs['order_detail'] = str2bool(self.request.query_params.get('order_detail', False)) - except AttributeError: - pass - - try: - kwargs['allocations'] = str2bool(self.request.query_params.get('allocations', False)) + kwargs['part_detail'] = str2bool(params.get('part_detail', False)) + kwargs['order_detail'] = str2bool(params.get('order_detail', False)) + kwargs['allocations'] = str2bool(params.get('allocations', False)) except AttributeError: pass @@ -486,6 +481,70 @@ class SOLineItemDetail(generics.RetrieveUpdateAPIView): serializer_class = SOLineItemSerializer +class SOAllocationList(generics.ListCreateAPIView): + """ + API endpoint for listing SalesOrderAllocation objects + """ + + queryset = SalesOrderAllocation.objects.all() + serializer_class = SalesOrderAllocationSerializer + + def get_serializer(self, *args, **kwargs): + + try: + params = self.request.query_params + + kwargs['part_detail'] = str2bool(params.get('part_detail', False)) + kwargs['item_detail'] = str2bool(params.get('item_detail', False)) + kwargs['order_detail'] = str2bool(params.get('order_detail', False)) + kwargs['location_detail'] = str2bool(params.get('location_detail', False)) + except AttributeError: + pass + + return self.serializer_class(*args, **kwargs) + + def filter_queryset(self, queryset): + + queryset = super().filter_queryset(queryset) + + # Filter by order + params = self.request.query_params + + # Filter by "part" reference + part = params.get('part', None) + + if part is not None: + queryset = queryset.filter(item__part=part) + + # Filter by "order" reference + order = params.get('order', None) + + if order is not None: + queryset = queryset.filter(line__order=order) + + # Filter by "outstanding" order status + outstanding = params.get('outstanding', None) + + if outstanding is not None: + outstanding = str2bool(outstanding) + + if outstanding: + queryset = queryset.filter(line__order__status__in=SalesOrderStatus.OPEN) + else: + queryset = queryset.exclude(line__order__status__in=SalesOrderStatus.OPEN) + + return queryset + + filter_backends = [ + DjangoFilterBackend, + ] + + # Default filterable fields + filter_fields = [ + 'item', + ] + + class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin): """ API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload) @@ -494,10 +553,6 @@ class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin): queryset = PurchaseOrderAttachment.objects.all() serializer_class = POAttachmentSerializer - filter_fields = [ - 'order', - ] - order_api_urls = [ # API endpoints for purchase orders @@ -512,14 +567,26 @@ order_api_urls = [ url(r'^po-line/$', POLineItemList.as_view(), name='api-po-line-list'), # API endpoints for sales ordesr - url(r'^so/(?P\d+)/$', SODetail.as_view(), name='api-so-detail'), - url(r'so/attachment/', include([ - url(r'^.*$', SOAttachmentList.as_view(), name='api-so-attachment-list'), + url(r'^so/', include([ + url(r'^(?P\d+)/$', SODetail.as_view(), name='api-so-detail'), + url(r'attachment/', include([ + url(r'^.*$', SOAttachmentList.as_view(), name='api-so-attachment-list'), + ])), + + # List all sales orders + url(r'^.*$', SOList.as_view(), name='api-so-list'), ])), - url(r'^so/.*$', SOList.as_view(), name='api-so-list'), - # API endpoints for sales order line items - url(r'^so-line/(?P\d+)/$', SOLineItemDetail.as_view(), name='api-so-line-detail'), - url(r'^so-line/$', SOLineItemList.as_view(), name='api-so-line-list'), + url(r'^so-line/', include([ + url(r'^(?P\d+)/$', SOLineItemDetail.as_view(), name='api-so-line-detail'), + url(r'^$', SOLineItemList.as_view(), name='api-so-line-list'), + ])), + + # API endpoints for sales order allocations + url(r'^so-allocation', include([ + + # List all sales order allocations + url(r'^.*$', SOAllocationList.as_view(), name='api-so-allocation-list'), + ])), ] diff --git a/InvenTree/order/fixtures/order.yaml b/InvenTree/order/fixtures/order.yaml index 6b65c20786..769f7702e5 100644 --- a/InvenTree/order/fixtures/order.yaml +++ b/InvenTree/order/fixtures/order.yaml @@ -68,6 +68,7 @@ order: 1 part: 1 quantity: 100 + destination: 5 # Desk/Drawer_1 # 250 x ACME0002 (M2x4 LPHS) # Partially received (50) @@ -95,3 +96,10 @@ part: 3 quantity: 100 +# 1 x R_4K7_0603 +- model: order.purchaseorderlineitem + pk: 23 + fields: + order: 1 + part: 5 + quantity: 1 diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py index 3973888e95..f094caafbe 100644 --- a/InvenTree/order/forms.py +++ b/InvenTree/order/forms.py @@ -11,9 +11,13 @@ from django.utils.translation import ugettext_lazy as _ from mptt.fields import TreeNodeChoiceField from InvenTree.forms import HelperForm -from InvenTree.fields import RoundingDecimalFormField +from InvenTree.fields import InvenTreeMoneyField, RoundingDecimalFormField from InvenTree.fields import DatePickerFormField +from InvenTree.helpers import clean_decimal + +from common.forms import MatchItemForm + import part.models from stock.models import StockLocation @@ -79,12 +83,17 @@ class ShipSalesOrderForm(HelperForm): class ReceivePurchaseOrderForm(HelperForm): - location = TreeNodeChoiceField(queryset=StockLocation.objects.all(), required=True, label=_('Location'), help_text=_('Receive parts to this location')) + location = TreeNodeChoiceField( + queryset=StockLocation.objects.all(), + required=True, + label=_("Destination"), + help_text=_("Receive parts to this location"), + ) class Meta: model = PurchaseOrder fields = [ - 'location', + "location", ] @@ -195,6 +204,7 @@ class EditPurchaseOrderLineItemForm(HelperForm): 'quantity', 'reference', 'purchase_price', + 'destination', 'notes', ] @@ -285,3 +295,36 @@ class EditSalesOrderAllocationForm(HelperForm): 'line', 'item', 'quantity'] + + +class OrderMatchItemForm(MatchItemForm): + """ Override MatchItemForm fields """ + + def get_special_field(self, col_guess, row, file_manager): + """ Set special fields """ + + # set quantity field + if 'quantity' in col_guess.lower(): + return forms.CharField( + required=False, + widget=forms.NumberInput(attrs={ + 'name': 'quantity' + str(row['index']), + 'class': 'numberinput', + 'type': 'number', + 'min': '0', + 'step': 'any', + 'value': clean_decimal(row.get('quantity', '')), + }) + ) + # set price field + elif 'price' in col_guess.lower(): + return InvenTreeMoneyField( + label=_(col_guess), + decimal_places=5, + max_digits=19, + required=False, + default_amount=clean_decimal(row.get('purchase_price', '')), + ) + + # return default + return super().get_special_field(col_guess, row, file_manager) diff --git a/InvenTree/order/migrations/0046_purchaseorderlineitem_destination.py b/InvenTree/order/migrations/0046_purchaseorderlineitem_destination.py new file mode 100644 index 0000000000..fa08c91e0d --- /dev/null +++ b/InvenTree/order/migrations/0046_purchaseorderlineitem_destination.py @@ -0,0 +1,29 @@ +# Generated by Django 3.2 on 2021-05-13 22:38 + +from django.db import migrations +import django.db.models.deletion +import mptt.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ("stock", "0063_auto_20210511_2343"), + ("order", "0045_auto_20210504_1946"), + ] + + operations = [ + migrations.AddField( + model_name="purchaseorderlineitem", + name="destination", + field=mptt.fields.TreeForeignKey( + blank=True, + help_text="Where does the Purchaser want this item to be stored?", + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="po_lines", + to="stock.stocklocation", + verbose_name="Destination", + ), + ), + ] diff --git a/InvenTree/order/migrations/0047_auto_20210701_0509.py b/InvenTree/order/migrations/0047_auto_20210701_0509.py new file mode 100644 index 0000000000..1f732e5f61 --- /dev/null +++ b/InvenTree/order/migrations/0047_auto_20210701_0509.py @@ -0,0 +1,35 @@ +# Generated by Django 3.2.4 on 2021-07-01 05:09 + +import InvenTree.fields +from django.db import migrations +import djmoney.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0046_purchaseorderlineitem_destination'), + ] + + operations = [ + migrations.AlterField( + model_name='purchaseorderlineitem', + name='purchase_price', + field=InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=4, default_currency='', help_text='Unit purchase price', max_digits=19, null=True, verbose_name='Purchase Price'), + ), + migrations.AlterField( + model_name='purchaseorderlineitem', + name='purchase_price_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3), + ), + migrations.AlterField( + model_name='salesorderlineitem', + name='sale_price', + field=InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=4, default_currency='', help_text='Unit sale price', max_digits=19, null=True, verbose_name='Sale Price'), + ), + migrations.AlterField( + model_name='salesorderlineitem', + name='sale_price_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3), + ), + ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 49504b6d89..669603f5ec 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -17,18 +17,15 @@ from django.contrib.auth.models import User from django.urls import reverse from django.utils.translation import ugettext_lazy as _ -from common.settings import currency_code_default - from markdownx.models import MarkdownxField - -from djmoney.models.fields import MoneyField +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 InvenTree.fields import RoundingDecimalField +from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField from InvenTree.helpers import decimal2string, increment, getSetting from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus, StockHistoryCode from InvenTree.models import InvenTreeAttachment @@ -663,15 +660,37 @@ class PurchaseOrderLineItem(OrderLineItem): received = models.DecimalField(decimal_places=5, max_digits=15, default=0, verbose_name=_('Received'), help_text=_('Number of items received')) - purchase_price = MoneyField( + purchase_price = InvenTreeModelMoneyField( max_digits=19, decimal_places=4, - default_currency=currency_code_default(), null=True, blank=True, verbose_name=_('Purchase Price'), help_text=_('Unit purchase price'), ) + destination = TreeForeignKey( + 'stock.StockLocation', on_delete=models.DO_NOTHING, + verbose_name=_('Destination'), + related_name='po_lines', + blank=True, null=True, + help_text=_('Where does the Purchaser want this item to be stored?') + ) + + def get_destination(self): + """Show where the line item is or should be placed""" + # NOTE: If a line item gets split when recieved, only an arbitrary + # stock items location will be reported as the location for the + # entire line. + for stock in stock_models.StockItem.objects.filter( + supplier_part=self.part, purchase_order=self.order + ): + if stock.location: + return stock.location + if self.destination: + return self.destination + if self.part and self.part.part and self.part.part.default_location: + return self.part.part.default_location + def remaining(self): """ Calculate the number of items remaining to be received """ r = self.quantity - self.received @@ -692,10 +711,9 @@ class SalesOrderLineItem(OrderLineItem): part = models.ForeignKey('part.Part', on_delete=models.SET_NULL, related_name='sales_order_line_items', null=True, verbose_name=_('Part'), help_text=_('Part'), limit_choices_to={'salable': True}) - sale_price = MoneyField( + sale_price = InvenTreeModelMoneyField( max_digits=19, decimal_places=4, - default_currency=currency_code_default(), null=True, blank=True, verbose_name=_('Sale Price'), help_text=_('Unit sale price'), diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 6091140313..e527b3cec9 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -17,6 +17,7 @@ from InvenTree.serializers import InvenTreeAttachmentSerializerField from company.serializers import CompanyBriefSerializer, SupplierPartSerializer from part.serializers import PartBriefSerializer +from stock.serializers import LocationBriefSerializer, StockItemSerializer, LocationSerializer from .models import PurchaseOrder, PurchaseOrderLineItem from .models import PurchaseOrderAttachment, SalesOrderAttachment @@ -41,7 +42,7 @@ class POSerializer(InvenTreeModelSerializer): """ Add extra information to the queryset - - Number of liens in the PurchaseOrder + - Number of lines in the PurchaseOrder - Overdue status of the PurchaseOrder """ @@ -91,8 +92,10 @@ class POSerializer(InvenTreeModelSerializer): ] read_only_fields = [ - 'reference', 'status' + 'issue_date', + 'complete_date', + 'creation_date', ] @@ -108,14 +111,17 @@ class POLineItemSerializer(InvenTreeModelSerializer): self.fields.pop('part_detail') self.fields.pop('supplier_part_detail') - quantity = serializers.FloatField() - received = serializers.FloatField() + # TODO: Once https://github.com/inventree/InvenTree/issues/1687 is fixed, remove default values + quantity = serializers.FloatField(default=1) + received = serializers.FloatField(default=0) part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True) supplier_part_detail = SupplierPartSerializer(source='part', many=False, read_only=True) purchase_price_string = serializers.CharField(source='purchase_price', read_only=True) + destination = LocationBriefSerializer(source='get_destination', read_only=True) + class Meta: model = PurchaseOrderLineItem @@ -132,6 +138,7 @@ class POLineItemSerializer(InvenTreeModelSerializer): 'purchase_price', 'purchase_price_currency', 'purchase_price_string', + 'destination', ] @@ -221,8 +228,9 @@ class SalesOrderSerializer(InvenTreeModelSerializer): ] read_only_fields = [ - 'reference', - 'status' + 'status', + 'creation_date', + 'shipment_date', ] @@ -232,11 +240,38 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer): This includes some fields from the related model objects. """ - location_path = serializers.CharField(source='get_location_path') - location_id = serializers.IntegerField(source='get_location') - serial = serializers.CharField(source='get_serial') - po = serializers.CharField(source='get_po') - quantity = serializers.FloatField() + part = serializers.PrimaryKeyRelatedField(source='item.part', read_only=True) + order = serializers.PrimaryKeyRelatedField(source='line.order', many=False, read_only=True) + serial = serializers.CharField(source='get_serial', read_only=True) + quantity = serializers.FloatField(read_only=True) + location = serializers.PrimaryKeyRelatedField(source='item.location', many=False, read_only=True) + + # Extra detail fields + order_detail = SalesOrderSerializer(source='line.order', many=False, read_only=True) + part_detail = PartBriefSerializer(source='item.part', many=False, read_only=True) + item_detail = StockItemSerializer(source='item', many=False, read_only=True) + location_detail = LocationSerializer(source='item.location', many=False, read_only=True) + + def __init__(self, *args, **kwargs): + + order_detail = kwargs.pop('order_detail', False) + part_detail = kwargs.pop('part_detail', False) + item_detail = kwargs.pop('item_detail', False) + location_detail = kwargs.pop('location_detail', False) + + super().__init__(*args, **kwargs) + + if not order_detail: + self.fields.pop('order_detail') + + if not part_detail: + self.fields.pop('part_detail') + + if not item_detail: + self.fields.pop('item_detail') + + if not location_detail: + self.fields.pop('location_detail') class Meta: model = SalesOrderAllocation @@ -246,10 +281,14 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer): 'line', 'serial', 'quantity', - 'location_id', - 'location_path', - 'po', + 'location', + 'location_detail', 'item', + 'item_detail', + 'order', + 'order_detail', + 'part', + 'part_detail', ] @@ -277,7 +316,9 @@ class SOLineItemSerializer(InvenTreeModelSerializer): part_detail = PartBriefSerializer(source='part', many=False, read_only=True) allocations = SalesOrderAllocationSerializer(many=True, read_only=True) - quantity = serializers.FloatField() + # TODO: Once https://github.com/inventree/InvenTree/issues/1687 is fixed, remove default values + quantity = serializers.FloatField(default=1) + allocated = serializers.FloatField(source='allocated_quantity', read_only=True) fulfilled = serializers.FloatField(source='fulfilled_quantity', read_only=True) sale_price_string = serializers.CharField(source='sale_price', read_only=True) diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html index 642f866506..c7ba6be8a4 100644 --- a/InvenTree/order/templates/order/order_base.html +++ b/InvenTree/order/templates/order/order_base.html @@ -75,7 +75,7 @@ src="{% static 'img/blank_image.png' %}" {% trans "Order Reference" %} - {{ order.reference }}{% include "clip.html"%} + {% settings_value 'PURCHASEORDER_REFERENCE_PREFIX' %}{{ order.reference }}{% include "clip.html"%} diff --git a/InvenTree/order/templates/order/order_wizard/match_parts.html b/InvenTree/order/templates/order/order_wizard/match_parts.html index f97edff913..e0f030bad5 100644 --- a/InvenTree/order/templates/order/order_wizard/match_parts.html +++ b/InvenTree/order/templates/order/order_wizard/match_parts.html @@ -2,6 +2,7 @@ {% load inventree_extras %} {% load i18n %} {% load static %} +{% load crispy_forms_tags %} {% block form_alert %} {% if form.errors %} @@ -67,7 +68,7 @@ {% for field in form.visible_fields %} {% if field.name == row.quantity %} - {{ field }} + {{ field|as_crispy_field }} {% endif %} {% endfor %} {% if row.errors.quantity %} @@ -80,19 +81,19 @@ {% if item.column.guess == 'Purchase_Price' %} {% for field in form.visible_fields %} {% if field.name == row.purchase_price %} - {{ field }} + {{ field|as_crispy_field }} {% endif %} {% endfor %} {% elif item.column.guess == 'Reference' %} {% for field in form.visible_fields %} {% if field.name == row.reference %} - {{ field }} + {{ field|as_crispy_field }} {% endif %} {% endfor %} {% elif item.column.guess == 'Notes' %} {% for field in form.visible_fields %} {% if field.name == row.notes %} - {{ field }} + {{ field|as_crispy_field }} {% endif %} {% endfor %} {% else %} diff --git a/InvenTree/order/templates/order/order_wizard/select_parts.html b/InvenTree/order/templates/order/order_wizard/select_parts.html index 28b4a36213..a02113fa18 100644 --- a/InvenTree/order/templates/order/order_wizard/select_parts.html +++ b/InvenTree/order/templates/order/order_wizard/select_parts.html @@ -4,6 +4,8 @@ {% load i18n %} {% block form %} +{% default_currency as currency %} +{% settings_value 'PART_SHOW_PRICE_IN_FORMS' as show_price %}

{% trans "Step 1 of 2 - Select Part Suppliers" %} @@ -49,7 +51,13 @@ diff --git a/InvenTree/order/templates/order/po_attachments.html b/InvenTree/order/templates/order/po_attachments.html index a0ab77975a..40052c1ec6 100644 --- a/InvenTree/order/templates/order/po_attachments.html +++ b/InvenTree/order/templates/order/po_attachments.html @@ -57,8 +57,6 @@ $("#attachment-table").on('click', '.attachment-delete-button', function() { var url = `/order/purchase-order/attachment/${button.attr('pk')}/delete/`; - console.log("url: " + url); - launchModalForm(url, { reload: true, }); diff --git a/InvenTree/order/templates/order/purchase_order_detail.html b/InvenTree/order/templates/order/purchase_order_detail.html index 08236fe773..0ad73923a2 100644 --- a/InvenTree/order/templates/order/purchase_order_detail.html +++ b/InvenTree/order/templates/order/purchase_order_detail.html @@ -139,7 +139,7 @@ $("#po-table").inventreeTable({ } }, footerFormatter: function() { - return 'Total' + return '{% trans "Total" %}' } }, { @@ -234,6 +234,10 @@ $("#po-table").inventreeTable({ return (progressA < progressB) ? 1 : -1; } }, + { + field: 'destination.pathstring', + title: '{% trans "Destination" %}', + }, { field: 'notes', title: '{% trans "Notes" %}', diff --git a/InvenTree/order/templates/order/receive_parts.html b/InvenTree/order/templates/order/receive_parts.html index 35ce4b6513..bfddf01ce9 100644 --- a/InvenTree/order/templates/order/receive_parts.html +++ b/InvenTree/order/templates/order/receive_parts.html @@ -22,6 +22,7 @@ {% trans "Received" %} {% trans "Receive" %} {% trans "Status" %} + {% trans "Destination" %} {% for line in lines %} @@ -53,6 +54,9 @@ + + {{ line.get_destination }} + + + + {% endfor %} + + + + + {% trans "Match Fields" %} + + {% for col in form %} + + {{ col }} + {% for duplicate in duplicates %} + {% if duplicate == col.value %} + + {% endif %} + {% endfor %} + + {% endfor %} + + {% for row in rows %} + {% with forloop.counter as row_index %} + + + + + {{ row_index }} + {% for item in row.data %} + + + {{ item }} + + {% endfor %} + + {% endwith %} + {% endfor %} + +{% endblock form_content %} + +{% block js_ready %} +{{ block.super }} + +$('.fieldselect').select2({ + width: '100%', + matcher: partialMatcher, +}); + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/import_wizard/ajax_match_references.html b/InvenTree/part/templates/part/import_wizard/ajax_match_references.html new file mode 100644 index 0000000000..e57fb066d3 --- /dev/null +++ b/InvenTree/part/templates/part/import_wizard/ajax_match_references.html @@ -0,0 +1,84 @@ +{% extends "part/import_wizard/ajax_part_upload.html" %} +{% load inventree_extras %} +{% load i18n %} +{% load static %} +{% load crispy_forms_tags %} + +{% block form_alert %} +{% if form.errors %} +{% endif %} +{% if form_errors %} + +{% endif %} +{% endblock form_alert %} + +{% block form_content %} + + + + {% trans "Row" %} + {% for col in columns %} + + + + + {% if col.guess %} + {{ col.guess }} + {% else %} + {{ col.name }} + {% endif %} + + {% endfor %} + + + + {% comment %} Dummy row for javascript del_row method {% endcomment %} + {% for row in rows %} + + + + + + {% add row.index 1 %} + + {% for item in row.data %} + + {% if item.column.guess %} + {% with row_name=item.column.guess|lower %} + {% for field in form.visible_fields %} + {% if field.name == row|keyvalue:row_name %} + {{ field|as_crispy_field }} + {% endif %} + {% endfor %} + {% endwith %} + {% else %} + {{ item.cell }} + {% endif %} + + + {% endfor %} + + {% endfor %} + +{% endblock form_content %} + +{% block form_buttons_bottom %} +{% endblock form_buttons_bottom %} + +{% block js_ready %} +{{ block.super }} + +$('.bomselect').select2({ + dropdownAutoWidth: true, + matcher: partialMatcher, +}); + +$('.currencyselect').select2({ + dropdownAutoWidth: true, +}); + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/import_wizard/ajax_part_upload.html b/InvenTree/part/templates/part/import_wizard/ajax_part_upload.html new file mode 100644 index 0000000000..f2a1e5c844 --- /dev/null +++ b/InvenTree/part/templates/part/import_wizard/ajax_part_upload.html @@ -0,0 +1,33 @@ +{% extends "modal_form.html" %} + +{% load inventree_extras %} +{% load i18n %} + +{% block form %} + +{% if roles.part.change %} + +

{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %} + {% if description %}- {{ description }}{% endif %}

+ + {% block form_alert %} + {% endblock form_alert %} + +
+ {% csrf_token %} + {% load crispy_forms_tags %} + + + {{ wizard.management_form }} + {% block form_content %} + {% crispy wizard.form %} + {% endblock form_content %} +
+ +{% else %} + +{% endif %} + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/import_wizard/match_fields.html b/InvenTree/part/templates/part/import_wizard/match_fields.html new file mode 100644 index 0000000000..ba709bc639 --- /dev/null +++ b/InvenTree/part/templates/part/import_wizard/match_fields.html @@ -0,0 +1,99 @@ +{% extends "part/import_wizard/part_upload.html" %} +{% load inventree_extras %} +{% load i18n %} +{% load static %} + +{% block form_alert %} +{% if missing_columns and missing_columns|length > 0 %} + +{% endif %} +{% if duplicates and duplicates|length > 0 %} + +{% endif %} +{% endblock form_alert %} + +{% block form_buttons_top %} + {% if wizard.steps.prev %} + + {% endif %} + +{% endblock form_buttons_top %} + +{% block form_content %} + + + {% trans "File Fields" %} + + {% for col in form %} + +
+ + {{ col.name }} + +
+ + {% endfor %} + + + + + {% trans "Match Fields" %} + + {% for col in form %} + + {{ col }} + {% for duplicate in duplicates %} + {% if duplicate == col.value %} + + {% endif %} + {% endfor %} + + {% endfor %} + + {% for row in rows %} + {% with forloop.counter as row_index %} + + + + + {{ row_index }} + {% for item in row.data %} + + + {{ item }} + + {% endfor %} + + {% endwith %} + {% endfor %} + +{% endblock form_content %} + +{% block form_buttons_bottom %} +{% endblock form_buttons_bottom %} + +{% block js_ready %} +{{ block.super }} + +$('.fieldselect').select2({ + width: '100%', + matcher: partialMatcher, +}); + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/import_wizard/match_references.html b/InvenTree/part/templates/part/import_wizard/match_references.html new file mode 100644 index 0000000000..99b9ccd191 --- /dev/null +++ b/InvenTree/part/templates/part/import_wizard/match_references.html @@ -0,0 +1,91 @@ +{% extends "part/import_wizard/part_upload.html" %} +{% load inventree_extras %} +{% load i18n %} +{% load static %} +{% load crispy_forms_tags %} + +{% block form_alert %} +{% if form.errors %} +{% endif %} +{% if form_errors %} + +{% endif %} +{% endblock form_alert %} + +{% block form_buttons_top %} + {% if wizard.steps.prev %} + + {% endif %} + +{% endblock form_buttons_top %} + +{% block form_content %} + + + + {% trans "Row" %} + {% for col in columns %} + + + + + {% if col.guess %} + {{ col.guess }} + {% else %} + {{ col.name }} + {% endif %} + + {% endfor %} + + + + {% comment %} Dummy row for javascript del_row method {% endcomment %} + {% for row in rows %} + + + + + + {% add row.index 1 %} + + {% for item in row.data %} + + {% if item.column.guess %} + {% with row_name=item.column.guess|lower %} + {% for field in form.visible_fields %} + {% if field.name == row|keyvalue:row_name %} + {{ field|as_crispy_field }} + {% endif %} + {% endfor %} + {% endwith %} + {% else %} + {{ item.cell }} + {% endif %} + + + {% endfor %} + + {% endfor %} + +{% endblock form_content %} + +{% block form_buttons_bottom %} +{% endblock form_buttons_bottom %} + +{% block js_ready %} +{{ block.super }} + +$('.bomselect').select2({ + dropdownAutoWidth: true, + matcher: partialMatcher, +}); + +$('.currencyselect').select2({ + dropdownAutoWidth: true, +}); + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/import_wizard/part_upload.html b/InvenTree/part/templates/part/import_wizard/part_upload.html new file mode 100644 index 0000000000..87809603bb --- /dev/null +++ b/InvenTree/part/templates/part/import_wizard/part_upload.html @@ -0,0 +1,61 @@ +{% extends "part/category.html" %} +{% load inventree_extras %} +{% load i18n %} +{% load static %} + +{% block menubar %} +{% include 'part/category_navbar.html' with tab='import' %} +{% endblock %} + +{% block category_content %} +
+
+

+ {% trans "Import Parts from File" %} + {{ wizard.form.media }} +

+
+
+ {% if roles.part.change %} + +

{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %} + {% if description %}- {{ description }}{% endif %}

+ + {% block form_alert %} + {% endblock form_alert %} + + + {% csrf_token %} + {% load crispy_forms_tags %} + + {% block form_buttons_top %} + {% endblock form_buttons_top %} + + + {{ wizard.management_form }} + {% block form_content %} + {% crispy wizard.form %} + {% endblock form_content %} +
+ + {% block form_buttons_bottom %} + {% if wizard.steps.prev %} + + {% endif %} + + + {% endblock form_buttons_bottom %} + + {% else %} + + {% endif %} +
+
+{% endblock %} + +{% block js_ready %} +{{ block.super }} + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/internal_prices.html b/InvenTree/part/templates/part/internal_prices.html new file mode 100644 index 0000000000..2f54f3bb64 --- /dev/null +++ b/InvenTree/part/templates/part/internal_prices.html @@ -0,0 +1,122 @@ +{% extends "part/part_base.html" %} +{% load static %} +{% load i18n %} +{% load inventree_extras %} + +{% block menubar %} +{% include 'part/navbar.html' with tab='internal-prices' %} +{% endblock %} + +{% block heading %} +{% trans "Internal Price Information" %} +{% endblock %} + +{% block details %} +{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %} +{% if show_internal_price and roles.sales_order.view %} +
+ +
+ + +
+ +{% else %} +
+

{% trans "Permission Denied" %}

+ +
+ {% trans "You do not have permission to view this page." %} +
+
+{% endif %} +{% endblock %} + +{% block js_ready %} +{{ block.super }} + +{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %} +{% if show_internal_price and roles.sales_order.view %} +function reloadPriceBreaks() { + $("#internal-price-break-table").bootstrapTable("refresh"); +} + +$('#new-internal-price-break').click(function() { + launchModalForm("{% url 'internal-price-break-create' %}", + { + success: reloadPriceBreaks, + data: { + part: {{ part.id }}, + } + } + ); +}); + +$('#internal-price-break-table').inventreeTable({ + name: 'internalprice', + formatNoMatches: function() { return "{% trans 'No internal price break information found' %}"; }, + queryParams: { + part: {{ part.id }}, + }, + url: "{% url 'api-part-internal-price-list' %}", + onPostBody: function() { + var table = $('#internal-price-break-table'); + + table.find('.button-internal-price-break-delete').click(function() { + var pk = $(this).attr('pk'); + + launchModalForm( + `/part/internal-price/${pk}/delete/`, + { + success: reloadPriceBreaks + } + ); + }); + + table.find('.button-internal-price-break-edit').click(function() { + var pk = $(this).attr('pk'); + + launchModalForm( + `/part/internal-price/${pk}/edit/`, + { + success: reloadPriceBreaks + } + ); + }); + }, + columns: [ + { + field: 'pk', + title: 'ID', + visible: false, + switchable: false, + }, + { + field: 'quantity', + title: '{% trans "Quantity" %}', + sortable: true, + }, + { + field: 'price', + title: '{% trans "Price" %}', + sortable: true, + formatter: function(value, row, index) { + var html = value; + + html += `
` + + html += makeIconButton('fa-edit icon-blue', 'button-internal-price-break-edit', row.pk, '{% trans "Edit internal price break" %}'); + html += makeIconButton('fa-trash-alt icon-red', 'button-internal-price-break-delete', row.pk, '{% trans "Delete internal price break" %}'); + + html += `
`; + + return html; + } + }, + ] +}) + +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/navbar.html b/InvenTree/part/templates/part/navbar.html index d1ed7e3d21..1fae6aaec2 100644 --- a/InvenTree/part/templates/part/navbar.html +++ b/InvenTree/part/templates/part/navbar.html @@ -2,6 +2,9 @@ {% load static %} {% load inventree_extras %} +{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %} +{% settings_value 'PART_SHOW_RELATED' as show_related %} +