mirror of
https://github.com/inventree/InvenTree.git
synced 2025-05-01 04:56:45 +00:00
Merge remote-tracking branch 'inventree/master' into l10
# Conflicts: # InvenTree/locale/de/LC_MESSAGES/django.po # InvenTree/locale/en/LC_MESSAGES/django.po # InvenTree/locale/es/LC_MESSAGES/django.po # InvenTree/locale/fr/LC_MESSAGES/django.po # InvenTree/locale/it/LC_MESSAGES/django.po # InvenTree/locale/ja/LC_MESSAGES/django.po # InvenTree/locale/pl/LC_MESSAGES/django.po # InvenTree/locale/ru/LC_MESSAGES/django.po # InvenTree/locale/tr/LC_MESSAGES/django.po # InvenTree/locale/zh/LC_MESSAGES/django.po
This commit is contained in:
commit
bb4a53bb81
49
.github/workflows/python.yaml
vendored
Normal file
49
.github/workflows/python.yaml
vendored
Normal file
@ -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
|
||||||
|
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -35,6 +35,9 @@ local_settings.py
|
|||||||
*.backup
|
*.backup
|
||||||
*.old
|
*.old
|
||||||
|
|
||||||
|
# Files used for testing
|
||||||
|
dummy_image.*
|
||||||
|
|
||||||
# Sphinx files
|
# Sphinx files
|
||||||
docs/_build
|
docs/_build
|
||||||
|
|
||||||
|
@ -18,6 +18,7 @@ class InvenTreeAPITestCase(APITestCase):
|
|||||||
email = 'test@testing.com'
|
email = 'test@testing.com'
|
||||||
|
|
||||||
superuser = False
|
superuser = False
|
||||||
|
is_staff = True
|
||||||
auto_login = True
|
auto_login = True
|
||||||
|
|
||||||
# Set list of roles automatically associated with the user
|
# Set list of roles automatically associated with the user
|
||||||
@ -40,6 +41,10 @@ class InvenTreeAPITestCase(APITestCase):
|
|||||||
|
|
||||||
if self.superuser:
|
if self.superuser:
|
||||||
self.user.is_superuser = True
|
self.user.is_superuser = True
|
||||||
|
|
||||||
|
if self.is_staff:
|
||||||
|
self.user.is_staff = True
|
||||||
|
|
||||||
self.user.save()
|
self.user.save()
|
||||||
|
|
||||||
for role in self.roles:
|
for role in self.roles:
|
||||||
@ -73,22 +78,50 @@ class InvenTreeAPITestCase(APITestCase):
|
|||||||
ruleset.save()
|
ruleset.save()
|
||||||
break
|
break
|
||||||
|
|
||||||
def get(self, url, data={}, code=200):
|
def get(self, url, data={}, expected_code=200):
|
||||||
"""
|
"""
|
||||||
Issue a GET request
|
Issue a GET request
|
||||||
"""
|
"""
|
||||||
|
|
||||||
response = self.client.get(url, data, format='json')
|
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
|
return response
|
||||||
|
|
||||||
def post(self, url, data):
|
def post(self, url, data, expected_code=None):
|
||||||
"""
|
"""
|
||||||
Issue a POST request
|
Issue a POST request
|
||||||
"""
|
"""
|
||||||
|
|
||||||
response = self.client.post(url, data=data, format='json')
|
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
|
return response
|
||||||
|
@ -4,7 +4,6 @@ import logging
|
|||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
from django.core.exceptions import AppRegistryNotReady
|
from django.core.exceptions import AppRegistryNotReady
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
from InvenTree.ready import isInTestMode, canAppAccessDatabase
|
from InvenTree.ready import isInTestMode, canAppAccessDatabase
|
||||||
import InvenTree.tasks
|
import InvenTree.tasks
|
||||||
@ -66,10 +65,11 @@ class InvenTreeConfig(AppConfig):
|
|||||||
from djmoney.contrib.exchange.models import ExchangeBackend
|
from djmoney.contrib.exchange.models import ExchangeBackend
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from InvenTree.tasks import update_exchange_rates
|
from InvenTree.tasks import update_exchange_rates
|
||||||
|
from common.settings import currency_code_default
|
||||||
except AppRegistryNotReady:
|
except AppRegistryNotReady:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
base_currency = settings.BASE_CURRENCY
|
base_currency = currency_code_default()
|
||||||
|
|
||||||
update = False
|
update = False
|
||||||
|
|
||||||
|
@ -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
|
from djmoney.contrib.exchange.backends.base import SimpleExchangeBackend
|
||||||
|
|
||||||
@ -22,8 +22,8 @@ class InvenTreeExchange(SimpleExchangeBackend):
|
|||||||
return {
|
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)
|
super().update_rates(base=base_currency, symbols=symbols)
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
import sys
|
||||||
|
|
||||||
from .validators import allowable_url_schemes
|
from .validators import allowable_url_schemes
|
||||||
|
|
||||||
@ -13,8 +14,11 @@ from django.core import validators
|
|||||||
from django import forms
|
from django import forms
|
||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from djmoney.models.fields import MoneyField as ModelMoneyField
|
||||||
|
from djmoney.forms.fields import MoneyField
|
||||||
|
|
||||||
import InvenTree.helpers
|
import InvenTree.helpers
|
||||||
|
import common.settings
|
||||||
|
|
||||||
|
|
||||||
class InvenTreeURLFormField(FormURLField):
|
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):
|
class DatePickerFormField(forms.DateField):
|
||||||
"""
|
"""
|
||||||
Custom date-picker field
|
Custom date-picker field
|
||||||
|
@ -21,6 +21,9 @@ import InvenTree.version
|
|||||||
|
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
from .settings import MEDIA_URL, STATIC_URL
|
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):
|
def getSetting(key, backup_value=None):
|
||||||
@ -247,6 +250,22 @@ def decimal2string(d):
|
|||||||
return s.rstrip("0").rstrip(".")
|
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='"'):
|
def WrapWithQuotes(text, quote='"'):
|
||||||
""" Wrap the supplied text with quotes
|
""" Wrap the supplied text with quotes
|
||||||
|
|
||||||
|
60
InvenTree/InvenTree/management/commands/rebuild_models.py
Normal file
60
InvenTree/InvenTree/management/commands/rebuild_models.py
Normal file
@ -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")
|
@ -12,7 +12,7 @@ def isInTestMode():
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def canAppAccessDatabase():
|
def canAppAccessDatabase(allow_test=False):
|
||||||
"""
|
"""
|
||||||
Returns True if the apps.py file can access database records.
|
Returns True if the apps.py file can access database records.
|
||||||
|
|
||||||
@ -26,19 +26,23 @@ def canAppAccessDatabase():
|
|||||||
'flush',
|
'flush',
|
||||||
'loaddata',
|
'loaddata',
|
||||||
'dumpdata',
|
'dumpdata',
|
||||||
'makemirations',
|
'makemigrations',
|
||||||
'migrate',
|
'migrate',
|
||||||
'check',
|
'check',
|
||||||
'mediarestore',
|
|
||||||
'shell',
|
'shell',
|
||||||
'createsuperuser',
|
'createsuperuser',
|
||||||
'wait_for_db',
|
'wait_for_db',
|
||||||
'prerender',
|
'prerender',
|
||||||
|
'rebuild',
|
||||||
'collectstatic',
|
'collectstatic',
|
||||||
'makemessages',
|
'makemessages',
|
||||||
'compilemessages',
|
'compilemessages',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if not allow_test:
|
||||||
|
# Override for testing mode?
|
||||||
|
excluded_commands.append('test')
|
||||||
|
|
||||||
for cmd in excluded_commands:
|
for cmd in excluded_commands:
|
||||||
if cmd in sys.argv:
|
if cmd in sys.argv:
|
||||||
return False
|
return False
|
||||||
|
@ -2,16 +2,19 @@
|
|||||||
Serializers used in various InvenTree apps
|
Serializers used in various InvenTree apps
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from rest_framework import serializers
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import User
|
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):
|
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.
|
but also ensures that the underlying model class data are checked on validation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def validate(self, data):
|
def __init__(self, instance=None, data=empty, **kwargs):
|
||||||
""" Perform serializer validation.
|
|
||||||
|
# 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,
|
In addition to running validators on the serializer fields,
|
||||||
this class ensures that the underlying model is also validated.
|
this class ensures that the underlying model is also validated.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Run any native validation checks first (may throw an ValidationError)
|
# Run any native validation checks first (may raise a ValidationError)
|
||||||
data = super(serializers.ModelSerializer, self).validate(data)
|
data = super().run_validation(data)
|
||||||
|
|
||||||
# Now ensure the underlying model is correct
|
# Now ensure the underlying model is correct
|
||||||
|
|
||||||
|
if not hasattr(self, 'instance') or self.instance is None:
|
||||||
|
# No instance exists (we are creating a new one)
|
||||||
instance = self.Meta.model(**data)
|
instance = self.Meta.model(**data)
|
||||||
instance.clean()
|
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
|
return data
|
||||||
|
|
||||||
@ -82,3 +170,17 @@ class InvenTreeAttachmentSerializerField(serializers.FileField):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
return os.path.join(str(settings.MEDIA_URL), str(value))
|
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))
|
||||||
|
@ -23,6 +23,7 @@ import moneyed
|
|||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.contrib.messages import constants as messages
|
||||||
|
|
||||||
|
|
||||||
def _is_true(x):
|
def _is_true(x):
|
||||||
@ -97,7 +98,7 @@ DOCKER = _is_true(get_setting(
|
|||||||
# Configure logging settings
|
# Configure logging settings
|
||||||
log_level = get_setting(
|
log_level = get_setting(
|
||||||
'INVENTREE_LOG_LEVEL',
|
'INVENTREE_LOG_LEVEL',
|
||||||
CONFIG.get('log_level', 'DEBUG')
|
CONFIG.get('log_level', 'WARNING')
|
||||||
)
|
)
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@ -521,10 +522,6 @@ for currency in CURRENCIES:
|
|||||||
print(f"Currency code '{currency}' is not supported")
|
print(f"Currency code '{currency}' is not supported")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
BASE_CURRENCY = get_setting(
|
|
||||||
'INVENTREE_BASE_CURRENCY',
|
|
||||||
CONFIG.get('base_currency', 'USD')
|
|
||||||
)
|
|
||||||
|
|
||||||
# Custom currency exchange backend
|
# Custom currency exchange backend
|
||||||
EXCHANGE_BACKEND = 'InvenTree.exchange.InvenTreeExchange'
|
EXCHANGE_BACKEND = 'InvenTree.exchange.InvenTreeExchange'
|
||||||
@ -611,3 +608,9 @@ IMPORT_EXPORT_USE_TRANSACTIONS = True
|
|||||||
INTERNAL_IPS = [
|
INTERNAL_IPS = [
|
||||||
'127.0.0.1',
|
'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',
|
||||||
|
}
|
||||||
|
@ -718,7 +718,7 @@
|
|||||||
position:relative;
|
position:relative;
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
max-height: calc(100vh - 200px) !important;
|
max-height: calc(100vh - 200px) !important;
|
||||||
overflow-y: scroll;
|
overflow-y: auto;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -58,7 +58,7 @@ function inventreeFormDataUpload(url, data, options={}) {
|
|||||||
xhr.setRequestHeader('X-CSRFToken', csrftoken);
|
xhr.setRequestHeader('X-CSRFToken', csrftoken);
|
||||||
},
|
},
|
||||||
url: url,
|
url: url,
|
||||||
method: 'POST',
|
method: options.method || 'POST',
|
||||||
data: data,
|
data: data,
|
||||||
processData: false,
|
processData: false,
|
||||||
contentType: false,
|
contentType: false,
|
||||||
|
@ -219,6 +219,7 @@ function enableDragAndDrop(element, url, options) {
|
|||||||
data - Other form data to upload
|
data - Other form data to upload
|
||||||
success - Callback function in case of success
|
success - Callback function in case of success
|
||||||
error - Callback function in case of error
|
error - Callback function in case of error
|
||||||
|
method - HTTP method
|
||||||
*/
|
*/
|
||||||
|
|
||||||
data = options.data || {};
|
data = options.data || {};
|
||||||
@ -254,7 +255,8 @@ function enableDragAndDrop(element, url, options) {
|
|||||||
if (options.error) {
|
if (options.error) {
|
||||||
options.error(xhr, status, error);
|
options.error(xhr, status, error);
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
method: options.method || 'POST',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
@ -4,9 +4,10 @@ Provides system status functionality checks.
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from django_q.models import Success
|
from django_q.models import Success
|
||||||
from django_q.monitor import Stat
|
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
|
Check to see if we have a result within the last 20 minutes
|
||||||
"""
|
"""
|
||||||
|
|
||||||
now = datetime.now()
|
now = timezone.now()
|
||||||
past = now - timedelta(minutes=20)
|
past = now - timedelta(minutes=20)
|
||||||
|
|
||||||
results = Success.objects.filter(
|
results = Success.objects.filter(
|
||||||
@ -60,21 +61,21 @@ def is_email_configured():
|
|||||||
|
|
||||||
# Display warning unless in test mode
|
# Display warning unless in test mode
|
||||||
if not settings.TESTING:
|
if not settings.TESTING:
|
||||||
logger.warning("EMAIL_HOST is not configured")
|
logger.debug("EMAIL_HOST is not configured")
|
||||||
|
|
||||||
if not settings.EMAIL_HOST_USER:
|
if not settings.EMAIL_HOST_USER:
|
||||||
configured = False
|
configured = False
|
||||||
|
|
||||||
# Display warning unless in test mode
|
# Display warning unless in test mode
|
||||||
if not settings.TESTING:
|
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:
|
if not settings.EMAIL_HOST_PASSWORD:
|
||||||
configured = False
|
configured = False
|
||||||
|
|
||||||
# Display warning unless in test mode
|
# Display warning unless in test mode
|
||||||
if not settings.TESTING:
|
if not settings.TESTING:
|
||||||
logger.warning("EMAIL_HOST_PASSWORD is not configured")
|
logger.debug("EMAIL_HOST_PASSWORD is not configured")
|
||||||
|
|
||||||
return configured
|
return configured
|
||||||
|
|
||||||
|
@ -28,7 +28,7 @@ def schedule_task(taskname, **kwargs):
|
|||||||
try:
|
try:
|
||||||
from django_q.models import Schedule
|
from django_q.models import Schedule
|
||||||
except (AppRegistryNotReady):
|
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
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -80,7 +80,7 @@ def heartbeat():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from django_q.models import Success
|
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:
|
except AppRegistryNotReady:
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -105,7 +105,7 @@ def delete_successful_tasks():
|
|||||||
try:
|
try:
|
||||||
from django_q.models import Success
|
from django_q.models import Success
|
||||||
except AppRegistryNotReady:
|
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
|
return
|
||||||
|
|
||||||
threshold = datetime.now() - timedelta(days=30)
|
threshold = datetime.now() - timedelta(days=30)
|
||||||
@ -126,6 +126,7 @@ def check_for_updates():
|
|||||||
import common.models
|
import common.models
|
||||||
except AppRegistryNotReady:
|
except AppRegistryNotReady:
|
||||||
# Apps not yet loaded!
|
# Apps not yet loaded!
|
||||||
|
logger.info("Could not perform 'check_for_updates' - App registry not ready")
|
||||||
return
|
return
|
||||||
|
|
||||||
response = requests.get('https://api.github.com/repos/inventree/inventree/releases/latest')
|
response = requests.get('https://api.github.com/repos/inventree/inventree/releases/latest')
|
||||||
@ -169,9 +170,10 @@ def update_exchange_rates():
|
|||||||
try:
|
try:
|
||||||
from InvenTree.exchange import InvenTreeExchange
|
from InvenTree.exchange import InvenTreeExchange
|
||||||
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
|
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
|
||||||
from django.conf import settings
|
from common.settings import currency_code_default, currency_codes
|
||||||
except AppRegistryNotReady:
|
except AppRegistryNotReady:
|
||||||
# Apps not yet loaded!
|
# Apps not yet loaded!
|
||||||
|
logger.info("Could not perform 'update_exchange_rates' - App registry not ready")
|
||||||
return
|
return
|
||||||
except:
|
except:
|
||||||
# Other error?
|
# Other error?
|
||||||
@ -190,14 +192,14 @@ def update_exchange_rates():
|
|||||||
backend = InvenTreeExchange()
|
backend = InvenTreeExchange()
|
||||||
print(f"Updating exchange rates from {backend.url}")
|
print(f"Updating exchange rates from {backend.url}")
|
||||||
|
|
||||||
base = settings.BASE_CURRENCY
|
base = currency_code_default()
|
||||||
|
|
||||||
print(f"Using base currency '{base}'")
|
print(f"Using base currency '{base}'")
|
||||||
|
|
||||||
backend.update_rates(base_currency=base)
|
backend.update_rates(base_currency=base)
|
||||||
|
|
||||||
# Remove any exchange rates which are not in the provided currencies
|
# 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):
|
def send_email(subject, body, recipients, from_email=None):
|
||||||
|
@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
from rest_framework import status
|
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 django.urls import reverse
|
||||||
|
|
||||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||||
@ -11,6 +16,87 @@ from users.models import RuleSet
|
|||||||
from base64 import b64encode
|
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):
|
class APITests(InvenTreeAPITestCase):
|
||||||
""" Tests for the InvenTree API """
|
""" Tests for the InvenTree API """
|
||||||
|
|
||||||
@ -77,7 +163,7 @@ class APITests(InvenTreeAPITestCase):
|
|||||||
self.assertIn('version', data)
|
self.assertIn('version', data)
|
||||||
self.assertIn('instance', data)
|
self.assertIn('instance', data)
|
||||||
|
|
||||||
self.assertEquals('InvenTree', data['server'])
|
self.assertEqual('InvenTree', data['server'])
|
||||||
|
|
||||||
def test_role_view(self):
|
def test_role_view(self):
|
||||||
"""
|
"""
|
||||||
|
@ -5,8 +5,6 @@ from django.test import TestCase
|
|||||||
import django.core.exceptions as django_exceptions
|
import django.core.exceptions as django_exceptions
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
from djmoney.money import Money
|
from djmoney.money import Money
|
||||||
from djmoney.contrib.exchange.models import Rate, convert_money
|
from djmoney.contrib.exchange.models import Rate, convert_money
|
||||||
from djmoney.contrib.exchange.exceptions import MissingRate
|
from djmoney.contrib.exchange.exceptions import MissingRate
|
||||||
@ -22,6 +20,7 @@ from decimal import Decimal
|
|||||||
import InvenTree.tasks
|
import InvenTree.tasks
|
||||||
|
|
||||||
from stock.models import StockLocation
|
from stock.models import StockLocation
|
||||||
|
from common.settings import currency_codes
|
||||||
|
|
||||||
|
|
||||||
class ValidatorTest(TestCase):
|
class ValidatorTest(TestCase):
|
||||||
@ -337,13 +336,11 @@ class CurrencyTests(TestCase):
|
|||||||
with self.assertRaises(MissingRate):
|
with self.assertRaises(MissingRate):
|
||||||
convert_money(Money(100, 'AUD'), 'USD')
|
convert_money(Money(100, 'AUD'), 'USD')
|
||||||
|
|
||||||
currencies = settings.CURRENCIES
|
|
||||||
|
|
||||||
InvenTree.tasks.update_exchange_rates()
|
InvenTree.tasks.update_exchange_rates()
|
||||||
|
|
||||||
rates = Rate.objects.all()
|
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
|
# Now that we have some exchange rate information, we can perform conversions
|
||||||
|
|
||||||
|
@ -8,21 +8,27 @@ import re
|
|||||||
|
|
||||||
import common.models
|
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
|
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:
|
v6 -> 2021-06-23
|
||||||
- The updated StockItem "history tracking" now uses a different interface
|
- 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
|
v4 -> 2021-06-01
|
||||||
- BOM items can now accept "variant stock" to be assigned against them
|
- BOM items can now accept "variant stock" to be assigned against them
|
||||||
- Many slight API tweaks were needed to get this to work properly!
|
- 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():
|
def inventreeInstanceName():
|
||||||
|
@ -12,7 +12,6 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from django.http import HttpResponse, JsonResponse, HttpResponseRedirect
|
from django.http import HttpResponse, JsonResponse, HttpResponseRedirect
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
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 django.views.generic.base import RedirectView, TemplateView
|
||||||
|
|
||||||
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
|
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
|
||||||
|
from common.settings import currency_code_default, currency_codes
|
||||||
|
|
||||||
from part.models import Part, PartCategory
|
from part.models import Part, PartCategory
|
||||||
from stock.models import StockLocation, StockItem
|
from stock.models import StockLocation, StockItem
|
||||||
@ -337,7 +337,7 @@ class AjaxMixin(InvenTreeRoleMixin):
|
|||||||
# Do nothing by default
|
# Do nothing by default
|
||||||
pass
|
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.
|
""" Render a JSON response based on specific class context.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -349,6 +349,9 @@ class AjaxMixin(InvenTreeRoleMixin):
|
|||||||
Returns:
|
Returns:
|
||||||
JSON response object
|
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():
|
if not request.is_ajax():
|
||||||
return HttpResponseRedirect('/')
|
return HttpResponseRedirect('/')
|
||||||
@ -817,8 +820,8 @@ class CurrencySettingsView(TemplateView):
|
|||||||
ctx = super().get_context_data(**kwargs).copy()
|
ctx = super().get_context_data(**kwargs).copy()
|
||||||
|
|
||||||
ctx['settings'] = InvenTreeSetting.objects.all().order_by('key')
|
ctx['settings'] = InvenTreeSetting.objects.all().order_by('key')
|
||||||
ctx["base_currency"] = settings.BASE_CURRENCY
|
ctx["base_currency"] = currency_code_default()
|
||||||
ctx["currencies"] = settings.CURRENCIES
|
ctx["currencies"] = currency_codes
|
||||||
|
|
||||||
ctx["rates"] = Rate.objects.filter(backend="InvenTreeExchange")
|
ctx["rates"] = Rate.objects.filter(backend="InvenTreeExchange")
|
||||||
|
|
||||||
|
@ -165,6 +165,19 @@ class BuildItemList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
serializer_class = BuildItemSerializer
|
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):
|
def get_queryset(self):
|
||||||
""" Override the queryset method,
|
""" Override the queryset method,
|
||||||
to allow filtering by stock_item.part
|
to allow filtering by stock_item.part
|
||||||
|
@ -40,6 +40,7 @@ def assign_bom_items(apps, schema_editor):
|
|||||||
except BomItem.DoesNotExist:
|
except BomItem.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
if count_total > 0:
|
||||||
print(f"Assigned BomItem for {count_valid}/{count_total} entries")
|
print(f"Assigned BomItem for {count_valid}/{count_total} entries")
|
||||||
|
|
||||||
|
|
||||||
|
@ -13,7 +13,8 @@ from rest_framework import serializers
|
|||||||
from InvenTree.serializers import InvenTreeModelSerializer
|
from InvenTree.serializers import InvenTreeModelSerializer
|
||||||
|
|
||||||
from stock.serializers import StockItemSerializerBrief
|
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
|
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)
|
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 = serializers.IntegerField(source='stock_item.part.pk', read_only=True)
|
||||||
part_name = serializers.CharField(source='stock_item.part.full_name', read_only=True)
|
location = serializers.IntegerField(source='stock_item.location.pk', read_only=True)
|
||||||
part_thumb = serializers.CharField(source='getStockItemThumbnail', 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)
|
stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True)
|
||||||
|
location_detail = LocationSerializer(source='stock_item.location', read_only=True)
|
||||||
|
|
||||||
quantity = serializers.FloatField()
|
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:
|
class Meta:
|
||||||
model = BuildItem
|
model = BuildItem
|
||||||
fields = [
|
fields = [
|
||||||
'pk',
|
'pk',
|
||||||
'bom_part',
|
'bom_part',
|
||||||
'build',
|
'build',
|
||||||
|
'build_detail',
|
||||||
'install_into',
|
'install_into',
|
||||||
|
'location',
|
||||||
|
'location_detail',
|
||||||
'part',
|
'part',
|
||||||
'part_name',
|
'part_detail',
|
||||||
'part_thumb',
|
|
||||||
'stock_item',
|
'stock_item',
|
||||||
'stock_item_detail',
|
'stock_item_detail',
|
||||||
'quantity'
|
'quantity'
|
||||||
|
@ -26,6 +26,8 @@ class FileManager:
|
|||||||
# Fields which would be helpful but are not required
|
# Fields which would be helpful but are not required
|
||||||
OPTIONAL_HEADERS = []
|
OPTIONAL_HEADERS = []
|
||||||
|
|
||||||
|
OPTIONAL_MATCH_HEADERS = []
|
||||||
|
|
||||||
EDITABLE_HEADERS = []
|
EDITABLE_HEADERS = []
|
||||||
|
|
||||||
HEADERS = []
|
HEADERS = []
|
||||||
@ -82,30 +84,17 @@ class FileManager:
|
|||||||
def update_headers(self):
|
def update_headers(self):
|
||||||
""" Update headers """
|
""" 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):
|
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:
|
if not self.name:
|
||||||
return
|
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
|
# Update headers
|
||||||
self.update_headers()
|
self.update_headers()
|
||||||
|
|
||||||
|
@ -8,12 +8,7 @@ from __future__ import unicode_literals
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from djmoney.forms.fields import MoneyField
|
|
||||||
|
|
||||||
from InvenTree.forms import HelperForm
|
from InvenTree.forms import HelperForm
|
||||||
from InvenTree.helpers import clean_decimal
|
|
||||||
|
|
||||||
from common.settings import currency_code_default
|
|
||||||
|
|
||||||
from .files import FileManager
|
from .files import FileManager
|
||||||
from .models import InvenTreeSetting
|
from .models import InvenTreeSetting
|
||||||
@ -32,7 +27,7 @@ class SettingEditForm(HelperForm):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class UploadFile(forms.Form):
|
class UploadFileForm(forms.Form):
|
||||||
""" Step 1 of FileManagementFormView """
|
""" Step 1 of FileManagementFormView """
|
||||||
|
|
||||||
file = forms.FileField(
|
file = forms.FileField(
|
||||||
@ -70,7 +65,7 @@ class UploadFile(forms.Form):
|
|||||||
return file
|
return file
|
||||||
|
|
||||||
|
|
||||||
class MatchField(forms.Form):
|
class MatchFieldForm(forms.Form):
|
||||||
""" Step 2 of FileManagementFormView """
|
""" Step 2 of FileManagementFormView """
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@ -103,7 +98,7 @@ class MatchField(forms.Form):
|
|||||||
self.fields[field_name].initial = col['guess']
|
self.fields[field_name].initial = col['guess']
|
||||||
|
|
||||||
|
|
||||||
class MatchItem(forms.Form):
|
class MatchItemForm(forms.Form):
|
||||||
""" Step 3 of FileManagementFormView """
|
""" Step 3 of FileManagementFormView """
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@ -131,24 +126,41 @@ class MatchItem(forms.Form):
|
|||||||
for col in row['data']:
|
for col in row['data']:
|
||||||
# Get column matching
|
# Get column matching
|
||||||
col_guess = col['column'].get('guess', None)
|
col_guess = col['column'].get('guess', None)
|
||||||
|
|
||||||
# Create input for required headers
|
|
||||||
if col_guess in file_manager.REQUIRED_HEADERS:
|
|
||||||
# Set field name
|
# Set field name
|
||||||
field_name = col_guess.lower() + '-' + str(row['index'])
|
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
|
||||||
|
elif col_guess in file_manager.REQUIRED_HEADERS:
|
||||||
|
# Get value
|
||||||
|
value = row.get(col_guess.lower(), '')
|
||||||
# Set field input box
|
# Set field input box
|
||||||
if 'quantity' in col_guess.lower():
|
|
||||||
self.fields[field_name] = forms.CharField(
|
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,
|
required=False,
|
||||||
widget=forms.NumberInput(attrs={
|
widget=forms.Select(attrs={
|
||||||
'name': 'quantity' + str(row['index']),
|
'class': 'select bomselect',
|
||||||
'class': 'numberinput', # form-control',
|
|
||||||
'type': 'number',
|
|
||||||
'min': '0',
|
|
||||||
'step': 'any',
|
|
||||||
'value': clean_decimal(row.get('quantity', '')),
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
# Update select box when match was found
|
||||||
|
if item_match:
|
||||||
|
self.fields[field_name].initial = item_match.id
|
||||||
|
|
||||||
# Create item selection box
|
# Create item selection box
|
||||||
elif col_guess in file_manager.ITEM_MATCH_HEADERS:
|
elif col_guess in file_manager.ITEM_MATCH_HEADERS:
|
||||||
@ -176,22 +188,15 @@ class MatchItem(forms.Form):
|
|||||||
|
|
||||||
# Optional entries
|
# Optional entries
|
||||||
elif col_guess in file_manager.OPTIONAL_HEADERS:
|
elif col_guess in file_manager.OPTIONAL_HEADERS:
|
||||||
# Set field name
|
|
||||||
field_name = col_guess.lower() + '-' + str(row['index'])
|
|
||||||
# Get value
|
# Get value
|
||||||
value = row.get(col_guess.lower(), '')
|
value = row.get(col_guess.lower(), '')
|
||||||
# Set field input box
|
# 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(
|
self.fields[field_name] = forms.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
initial=value,
|
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
|
||||||
|
23
InvenTree/common/migrations/0010_migrate_currency_setting.py
Normal file
23
InvenTree/common/migrations/0010_migrate_currency_setting.py
Normal file
@ -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),
|
||||||
|
]
|
@ -14,11 +14,11 @@ from django.db import models, transaction
|
|||||||
from django.db.utils import IntegrityError, OperationalError
|
from django.db.utils import IntegrityError, OperationalError
|
||||||
from django.conf import settings
|
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.models import convert_money
|
||||||
from djmoney.contrib.exchange.exceptions import MissingRate
|
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.utils.translation import ugettext_lazy as _
|
||||||
from django.core.validators import MinValueValidator, URLValidator
|
from django.core.validators import MinValueValidator, URLValidator
|
||||||
@ -81,6 +81,13 @@ class InvenTreeSetting(models.Model):
|
|||||||
'default': '',
|
'default': '',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'INVENTREE_DEFAULT_CURRENCY': {
|
||||||
|
'name': _('Default Currency'),
|
||||||
|
'description': _('Default currency'),
|
||||||
|
'default': 'USD',
|
||||||
|
'choices': CURRENCY_CHOICES,
|
||||||
|
},
|
||||||
|
|
||||||
'INVENTREE_DOWNLOAD_FROM_URL': {
|
'INVENTREE_DOWNLOAD_FROM_URL': {
|
||||||
'name': _('Download from URL'),
|
'name': _('Download from URL'),
|
||||||
'description': _('Allow download of remote images and files from external URL'),
|
'description': _('Allow download of remote images and files from external URL'),
|
||||||
@ -205,6 +212,41 @@ class InvenTreeSetting(models.Model):
|
|||||||
'validator': bool,
|
'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': {
|
'REPORT_DEBUG_MODE': {
|
||||||
'name': _('Debug Mode'),
|
'name': _('Debug Mode'),
|
||||||
'description': _('Generate reports in debug mode (HTML output)'),
|
'description': _('Generate reports in debug mode (HTML output)'),
|
||||||
@ -700,10 +742,9 @@ class PriceBreak(models.Model):
|
|||||||
help_text=_('Price break quantity'),
|
help_text=_('Price break quantity'),
|
||||||
)
|
)
|
||||||
|
|
||||||
price = MoneyField(
|
price = InvenTree.fields.InvenTreeModelMoneyField(
|
||||||
max_digits=19,
|
max_digits=19,
|
||||||
decimal_places=4,
|
decimal_places=4,
|
||||||
default_currency=currency_code_default(),
|
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name=_('Price'),
|
verbose_name=_('Price'),
|
||||||
help_text=_('Unit price at specified quantity'),
|
help_text=_('Unit price at specified quantity'),
|
||||||
@ -726,7 +767,7 @@ class PriceBreak(models.Model):
|
|||||||
return converted.amount
|
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.
|
""" Calculate the price based on quantity price breaks.
|
||||||
|
|
||||||
- Don't forget to add in flat-fee cost (base_cost field)
|
- 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
|
- 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?
|
# No price break information available?
|
||||||
if len(price_breaks) == 0:
|
if len(price_breaks) == 0:
|
||||||
@ -753,10 +797,10 @@ def get_price(instance, quantity, moq=True, multiples=True, currency=None):
|
|||||||
|
|
||||||
if currency is None:
|
if currency is None:
|
||||||
# Default currency selection
|
# Default currency selection
|
||||||
currency = currency_code_default()
|
currency = common.settings.currency_code_default()
|
||||||
|
|
||||||
pb_min = None
|
pb_min = None
|
||||||
for pb in instance.price_breaks.all():
|
for pb in price_breaks:
|
||||||
# Store smallest price break
|
# Store smallest price break
|
||||||
if not pb_min:
|
if not pb_min:
|
||||||
pb_min = pb
|
pb_min = pb
|
||||||
|
@ -6,9 +6,9 @@ User-configurable settings for the common app
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from moneyed import CURRENCIES
|
from moneyed import CURRENCIES
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
import common.models
|
import common.models
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
|
|
||||||
def currency_code_default():
|
def currency_code_default():
|
||||||
@ -16,7 +16,7 @@ def currency_code_default():
|
|||||||
Returns the default currency code (or USD if not specified)
|
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:
|
if code not in CURRENCIES:
|
||||||
code = 'USD'
|
code = 'USD'
|
||||||
@ -24,6 +24,20 @@ def currency_code_default():
|
|||||||
return code
|
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():
|
def stock_expiry_enabled():
|
||||||
"""
|
"""
|
||||||
Returns True if the stock expiry feature is enabled
|
Returns True if the stock expiry feature is enabled
|
||||||
|
@ -13,8 +13,9 @@ from django.conf import settings
|
|||||||
from django.core.files.storage import FileSystemStorage
|
from django.core.files.storage import FileSystemStorage
|
||||||
|
|
||||||
from formtools.wizard.views import SessionWizardView
|
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 InvenTree.helpers import str2bool
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
@ -117,7 +118,6 @@ class MultiStepFormView(SessionWizardView):
|
|||||||
form_steps_description: description for each form
|
form_steps_description: description for each form
|
||||||
"""
|
"""
|
||||||
|
|
||||||
form_list = []
|
|
||||||
form_steps_template = []
|
form_steps_template = []
|
||||||
form_steps_description = []
|
form_steps_description = []
|
||||||
file_manager = None
|
file_manager = None
|
||||||
@ -126,7 +126,7 @@ class MultiStepFormView(SessionWizardView):
|
|||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
""" Override init method to set media folder """
|
""" Override init method to set media folder """
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
self.process_media_folder()
|
self.process_media_folder()
|
||||||
|
|
||||||
@ -176,9 +176,9 @@ class FileManagementFormView(MultiStepFormView):
|
|||||||
|
|
||||||
name = None
|
name = None
|
||||||
form_list = [
|
form_list = [
|
||||||
('upload', forms.UploadFile),
|
('upload', forms.UploadFileForm),
|
||||||
('fields', forms.MatchField),
|
('fields', forms.MatchFieldForm),
|
||||||
('items', forms.MatchItem),
|
('items', forms.MatchItemForm),
|
||||||
]
|
]
|
||||||
form_steps_description = [
|
form_steps_description = [
|
||||||
_("Upload File"),
|
_("Upload File"),
|
||||||
@ -188,7 +188,22 @@ class FileManagementFormView(MultiStepFormView):
|
|||||||
media_folder = 'file_upload/'
|
media_folder = 'file_upload/'
|
||||||
extra_context_data = {}
|
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)
|
context = super().get_context_data(form=form, **kwargs)
|
||||||
|
|
||||||
if self.steps.current in ('fields', 'items'):
|
if self.steps.current in ('fields', 'items'):
|
||||||
@ -227,7 +242,7 @@ class FileManagementFormView(MultiStepFormView):
|
|||||||
# Get file
|
# Get file
|
||||||
file = upload_files.get('upload-file', None)
|
file = upload_files.get('upload-file', None)
|
||||||
if file:
|
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):
|
def get_form_kwargs(self, step=None):
|
||||||
""" Update kwargs to dynamically build forms """
|
""" Update kwargs to dynamically build forms """
|
||||||
@ -269,6 +284,15 @@ class FileManagementFormView(MultiStepFormView):
|
|||||||
|
|
||||||
return super().get_form_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):
|
def get_form_table_data(self, form_data):
|
||||||
""" Extract table cell data from form data and fields.
|
""" Extract table cell data from form data and fields.
|
||||||
These data are used to maintain state between sessions.
|
These data are used to maintain state between sessions.
|
||||||
@ -375,6 +399,7 @@ class FileManagementFormView(MultiStepFormView):
|
|||||||
'data': data,
|
'data': data,
|
||||||
'errors': {},
|
'errors': {},
|
||||||
}
|
}
|
||||||
|
|
||||||
self.rows.append(row)
|
self.rows.append(row)
|
||||||
|
|
||||||
# In the item selection step: update row data with mapping to form fields
|
# In the item selection step: update row data with mapping to form fields
|
||||||
@ -414,6 +439,33 @@ class FileManagementFormView(MultiStepFormView):
|
|||||||
"""
|
"""
|
||||||
pass
|
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):
|
def check_field_selection(self, form):
|
||||||
""" Check field matching """
|
""" Check field matching """
|
||||||
|
|
||||||
@ -497,3 +549,70 @@ class FileManagementFormView(MultiStepFormView):
|
|||||||
return self.render(form)
|
return self.render(form)
|
||||||
|
|
||||||
return super().post(*args, **kwargs)
|
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!')
|
||||||
|
@ -11,6 +11,7 @@ import import_export.widgets as widgets
|
|||||||
from .models import Company
|
from .models import Company
|
||||||
from .models import SupplierPart
|
from .models import SupplierPart
|
||||||
from .models import SupplierPriceBreak
|
from .models import SupplierPriceBreak
|
||||||
|
from .models import ManufacturerPart, ManufacturerPartParameter
|
||||||
|
|
||||||
from part.models import Part
|
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 SupplierPriceBreakResource(ModelResource):
|
||||||
""" Class for managing SupplierPriceBreak data import/export """
|
""" Class for managing SupplierPriceBreak data import/export """
|
||||||
|
|
||||||
@ -103,3 +190,6 @@ class SupplierPriceBreakAdmin(ImportExportModelAdmin):
|
|||||||
admin.site.register(Company, CompanyAdmin)
|
admin.site.register(Company, CompanyAdmin)
|
||||||
admin.site.register(SupplierPart, SupplierPartAdmin)
|
admin.site.register(SupplierPart, SupplierPartAdmin)
|
||||||
admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin)
|
admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin)
|
||||||
|
|
||||||
|
admin.site.register(ManufacturerPart, ManufacturerPartAdmin)
|
||||||
|
admin.site.register(ManufacturerPartParameter, ManufacturerPartParameterAdmin)
|
||||||
|
@ -15,11 +15,11 @@ from django.db.models import Q
|
|||||||
from InvenTree.helpers import str2bool
|
from InvenTree.helpers import str2bool
|
||||||
|
|
||||||
from .models import Company
|
from .models import Company
|
||||||
from .models import ManufacturerPart
|
from .models import ManufacturerPart, ManufacturerPartParameter
|
||||||
from .models import SupplierPart, SupplierPriceBreak
|
from .models import SupplierPart, SupplierPriceBreak
|
||||||
|
|
||||||
from .serializers import CompanySerializer
|
from .serializers import CompanySerializer
|
||||||
from .serializers import ManufacturerPartSerializer
|
from .serializers import ManufacturerPartSerializer, ManufacturerPartParameterSerializer
|
||||||
from .serializers import SupplierPartSerializer, SupplierPriceBreakSerializer
|
from .serializers import SupplierPartSerializer, SupplierPriceBreakSerializer
|
||||||
|
|
||||||
|
|
||||||
@ -103,17 +103,11 @@ class ManufacturerPartList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
# Do we wish to include extra detail?
|
# Do we wish to include extra detail?
|
||||||
try:
|
try:
|
||||||
kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', None))
|
params = self.request.query_params
|
||||||
except AttributeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
kwargs['part_detail'] = str2bool(params.get('part_detail', None))
|
||||||
kwargs['manufacturer_detail'] = str2bool(self.request.query_params.get('manufacturer_detail', None))
|
kwargs['manufacturer_detail'] = str2bool(params.get('manufacturer_detail', None))
|
||||||
except AttributeError:
|
kwargs['pretty'] = str2bool(params.get('pretty', None))
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
kwargs['pretty'] = str2bool(self.request.query_params.get('pretty', None))
|
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -181,6 +175,86 @@ class ManufacturerPartDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
serializer_class = ManufacturerPartSerializer
|
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):
|
class SupplierPartList(generics.ListCreateAPIView):
|
||||||
""" API endpoint for list view of SupplierPart object
|
""" API endpoint for list view of SupplierPart object
|
||||||
|
|
||||||
@ -252,22 +326,11 @@ class SupplierPartList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
# Do we wish to include extra detail?
|
# Do we wish to include extra detail?
|
||||||
try:
|
try:
|
||||||
kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', None))
|
params = self.request.query_params
|
||||||
except AttributeError:
|
kwargs['part_detail'] = str2bool(params.get('part_detail', None))
|
||||||
pass
|
kwargs['supplier_detail'] = str2bool(params.get('supplier_detail', None))
|
||||||
|
kwargs['manufacturer_detail'] = str2bool(params.get('manufacturer_detail', None))
|
||||||
try:
|
kwargs['pretty'] = str2bool(params.get('pretty', None))
|
||||||
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))
|
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -333,6 +396,13 @@ class SupplierPriceBreakList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
manufacturer_part_api_urls = [
|
manufacturer_part_api_urls = [
|
||||||
|
|
||||||
|
url(r'^parameter/', include([
|
||||||
|
url(r'^(?P<pk>\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<pk>\d+)/?', ManufacturerPartDetail.as_view(), name='api-manufacturer-part-detail'),
|
url(r'^(?P<pk>\d+)/?', ManufacturerPartDetail.as_view(), name='api-manufacturer-part-detail'),
|
||||||
|
|
||||||
# Catch anything else
|
# Catch anything else
|
||||||
|
@ -44,8 +44,6 @@ class CompanyConfig(AppConfig):
|
|||||||
company.image.render_variations(replace=False)
|
company.image.render_variations(replace=False)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
logger.warning(f"Image file '{company.image}' missing")
|
logger.warning(f"Image file '{company.image}' missing")
|
||||||
company.image = None
|
|
||||||
company.save()
|
|
||||||
except UnidentifiedImageError:
|
except UnidentifiedImageError:
|
||||||
logger.warning(f"Image file '{company.image}' is invalid")
|
logger.warning(f"Image file '{company.image}' is invalid")
|
||||||
except (OperationalError, ProgrammingError):
|
except (OperationalError, ProgrammingError):
|
||||||
|
@ -52,3 +52,10 @@
|
|||||||
part: 2
|
part: 2
|
||||||
supplier: 2
|
supplier: 2
|
||||||
SKU: 'ZERGM312'
|
SKU: 'ZERGM312'
|
||||||
|
|
||||||
|
- model: company.supplierpart
|
||||||
|
pk: 5
|
||||||
|
fields:
|
||||||
|
part: 4
|
||||||
|
supplier: 2
|
||||||
|
SKU: 'R_4K7_0603'
|
||||||
|
@ -6,17 +6,16 @@ Django Forms for interacting with Company app
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from InvenTree.forms import HelperForm
|
from InvenTree.forms import HelperForm
|
||||||
from InvenTree.fields import RoundingDecimalFormField
|
from InvenTree.fields import InvenTreeMoneyField, RoundingDecimalFormField
|
||||||
|
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
import django.forms
|
import django.forms
|
||||||
|
|
||||||
import djmoney.settings
|
import djmoney.settings
|
||||||
from djmoney.forms.fields import MoneyField
|
|
||||||
|
|
||||||
from common.settings import currency_code_default
|
from common.settings import currency_code_default
|
||||||
|
|
||||||
from .models import Company
|
from .models import Company, ManufacturerPartParameter
|
||||||
from .models import ManufacturerPart
|
from .models import ManufacturerPart
|
||||||
from .models import SupplierPart
|
from .models import SupplierPart
|
||||||
from .models import SupplierPriceBreak
|
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):
|
class EditSupplierPartForm(HelperForm):
|
||||||
""" Form for editing a SupplierPart object """
|
""" Form for editing a SupplierPart object """
|
||||||
|
|
||||||
@ -114,9 +128,8 @@ class EditSupplierPartForm(HelperForm):
|
|||||||
'note': 'fa-pencil-alt',
|
'note': 'fa-pencil-alt',
|
||||||
}
|
}
|
||||||
|
|
||||||
single_pricing = MoneyField(
|
single_pricing = InvenTreeMoneyField(
|
||||||
label=_('Single Price'),
|
label=_('Single Price'),
|
||||||
default_currency=currency_code_default(),
|
|
||||||
help_text=_('Single quantity price'),
|
help_text=_('Single quantity price'),
|
||||||
decimal_places=4,
|
decimal_places=4,
|
||||||
max_digits=19,
|
max_digits=19,
|
||||||
|
@ -71,6 +71,7 @@ def migrate_currencies(apps, schema_editor):
|
|||||||
|
|
||||||
count += 1
|
count += 1
|
||||||
|
|
||||||
|
if count > 0:
|
||||||
print(f"Updated {count} SupplierPriceBreak rows")
|
print(f"Updated {count} SupplierPriceBreak rows")
|
||||||
|
|
||||||
def reverse_currencies(apps, schema_editor):
|
def reverse_currencies(apps, schema_editor):
|
||||||
|
@ -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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
25
InvenTree/company/migrations/0039_auto_20210701_0509.py
Normal file
25
InvenTree/company/migrations/0039_auto_20210701_0509.py
Normal file
@ -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),
|
||||||
|
),
|
||||||
|
]
|
@ -371,6 +371,47 @@ class ManufacturerPart(models.Model):
|
|||||||
return s
|
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):
|
class SupplierPart(models.Model):
|
||||||
""" Represents a unique part as provided by a Supplier
|
""" Represents a unique part as provided by a Supplier
|
||||||
Each SupplierPart is identified by a SKU (Supplier Part Number)
|
Each SupplierPart is identified by a SKU (Supplier Part Number)
|
||||||
|
@ -6,14 +6,15 @@ from rest_framework import serializers
|
|||||||
|
|
||||||
from sql_util.utils import SubqueryCount
|
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 InvenTreeModelSerializer
|
||||||
|
from InvenTree.serializers import InvenTreeImageSerializerField
|
||||||
|
|
||||||
from part.serializers import PartBriefSerializer
|
from part.serializers import PartBriefSerializer
|
||||||
|
|
||||||
|
from .models import Company
|
||||||
|
from .models import ManufacturerPart, ManufacturerPartParameter
|
||||||
|
from .models import SupplierPart, SupplierPriceBreak
|
||||||
|
|
||||||
|
|
||||||
class CompanyBriefSerializer(InvenTreeModelSerializer):
|
class CompanyBriefSerializer(InvenTreeModelSerializer):
|
||||||
""" Serializer for Company object (limited detail) """
|
""" Serializer for Company object (limited detail) """
|
||||||
@ -52,7 +53,7 @@ class CompanySerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
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_supplied = serializers.IntegerField(read_only=True)
|
||||||
parts_manufactured = 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):
|
class SupplierPartSerializer(InvenTreeModelSerializer):
|
||||||
""" Serializer for SupplierPart object """
|
""" Serializer for SupplierPart object """
|
||||||
|
|
||||||
|
@ -139,13 +139,19 @@
|
|||||||
|
|
||||||
enableDragAndDrop(
|
enableDragAndDrop(
|
||||||
"#company-thumb",
|
"#company-thumb",
|
||||||
"{% url 'company-image' company.id %}",
|
"{% url 'api-company-detail' company.id %}",
|
||||||
{
|
{
|
||||||
label: 'image',
|
label: 'image',
|
||||||
|
method: 'PATCH',
|
||||||
success: function(data, status, xhr) {
|
success: function(data, status, xhr) {
|
||||||
|
|
||||||
|
if (data.image) {
|
||||||
|
$('#company-image').attr('src', data.image);
|
||||||
|
} else {
|
||||||
location.reload();
|
location.reload();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
{% if company.image %}
|
{% if company.image %}
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block heading %}
|
{% block heading %}
|
||||||
{% trans "Supplier Parts" %}
|
{% trans "Suppliers" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block details %}
|
{% block details %}
|
||||||
@ -30,9 +30,44 @@
|
|||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block post_content_panels %}
|
||||||
|
|
||||||
|
<div class='panel panel-default panel-inventree'>
|
||||||
|
<div class='panel-heading'>
|
||||||
|
<h4>{% trans "Parameters" %}</h4>
|
||||||
|
</div>
|
||||||
|
<div class='panel-content'>
|
||||||
|
<div id='parameter-toolbar'>
|
||||||
|
<div class='btn-group'>
|
||||||
|
<button class='btn btn-success' id='parameter-create'>
|
||||||
|
<span class='fas fa-plus-circle'></span> {% trans "New Parameter" %}
|
||||||
|
</button>
|
||||||
|
<div id='param-dropdown' class='btn-group'>
|
||||||
|
<!-- TODO -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class='table table-striped table-condensed' id='parameter-table' data-toolbar='#parameter-toolbar'></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block js_ready %}
|
{% block js_ready %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
|
||||||
|
$('#parameter-create').click(function() {
|
||||||
|
launchModalForm(
|
||||||
|
"{% url 'manufacturer-part-parameter-create' %}",
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
manufacturer_part: {{ part.id }},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
$('#supplier-create').click(function () {
|
$('#supplier-create').click(function () {
|
||||||
launchModalForm(
|
launchModalForm(
|
||||||
"{% url 'supplier-part-create' %}",
|
"{% 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'])
|
linkButtonsToSelection($("#supplier-table"), ['#supplier-part-options'])
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
@ -50,10 +50,15 @@ class CompanyTest(InvenTreeAPITestCase):
|
|||||||
self.assertEqual(response.data['name'], 'ACME')
|
self.assertEqual(response.data['name'], 'ACME')
|
||||||
|
|
||||||
# Change the name of the company
|
# Change the name of the company
|
||||||
|
# Note we should not have the correct permissions (yet)
|
||||||
data = response.data
|
data = response.data
|
||||||
data['name'] = 'ACMOO'
|
data['name'] = 'ACMOO'
|
||||||
response = self.client.patch(url, data, format='json')
|
response = self.client.patch(url, data, format='json', expected_code=400)
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
|
self.assignRole('company.change')
|
||||||
|
|
||||||
|
response = self.client.patch(url, data, format='json', expected_code=200)
|
||||||
|
|
||||||
self.assertEqual(response.data['name'], 'ACMOO')
|
self.assertEqual(response.data['name'], 'ACMOO')
|
||||||
|
|
||||||
def test_company_search(self):
|
def test_company_search(self):
|
||||||
@ -119,7 +124,9 @@ class ManufacturerTest(InvenTreeAPITestCase):
|
|||||||
data = {
|
data = {
|
||||||
'MPN': 'MPN-TEST-123',
|
'MPN': 'MPN-TEST-123',
|
||||||
}
|
}
|
||||||
|
|
||||||
response = self.client.patch(url, data, format='json')
|
response = self.client.patch(url, data, format='json')
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(response.data['MPN'], 'MPN-TEST-123')
|
self.assertEqual(response.data['MPN'], 'MPN-TEST-123')
|
||||||
|
|
||||||
|
@ -65,7 +65,7 @@ class CompanySimpleTest(TestCase):
|
|||||||
self.assertEqual(acme.supplied_part_count, 4)
|
self.assertEqual(acme.supplied_part_count, 4)
|
||||||
|
|
||||||
self.assertTrue(appel.has_parts)
|
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.assertTrue(zerg.has_parts)
|
||||||
self.assertEqual(zerg.supplied_part_count, 2)
|
self.assertEqual(zerg.supplied_part_count, 2)
|
||||||
|
@ -53,20 +53,25 @@ price_break_urls = [
|
|||||||
url(r'^(?P<pk>\d+)/delete/', views.PriceBreakDelete.as_view(), name='price-break-delete'),
|
url(r'^(?P<pk>\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 = [
|
manufacturer_part_urls = [
|
||||||
url(r'^new/?', views.ManufacturerPartCreate.as_view(), name='manufacturer-part-create'),
|
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<pk>\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<pk>\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<pk>\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 = [
|
supplier_part_detail_urls = [
|
||||||
|
@ -23,14 +23,14 @@ from InvenTree.views import AjaxCreateView, AjaxUpdateView, AjaxDeleteView
|
|||||||
from InvenTree.helpers import str2bool
|
from InvenTree.helpers import str2bool
|
||||||
from InvenTree.views import InvenTreeRoleMixin
|
from InvenTree.views import InvenTreeRoleMixin
|
||||||
|
|
||||||
from .models import Company
|
from .models import Company, ManufacturerPartParameter
|
||||||
from .models import ManufacturerPart
|
from .models import ManufacturerPart
|
||||||
from .models import SupplierPart
|
from .models import SupplierPart
|
||||||
from .models import SupplierPriceBreak
|
from .models import SupplierPriceBreak
|
||||||
|
|
||||||
from part.models import Part
|
from part.models import Part
|
||||||
|
|
||||||
from .forms import EditCompanyForm
|
from .forms import EditCompanyForm, EditManufacturerPartParameterForm
|
||||||
from .forms import CompanyImageForm
|
from .forms import CompanyImageForm
|
||||||
from .forms import EditManufacturerPartForm
|
from .forms import EditManufacturerPartForm
|
||||||
from .forms import EditSupplierPartForm
|
from .forms import EditSupplierPartForm
|
||||||
@ -504,6 +504,66 @@ class ManufacturerPartDelete(AjaxDeleteView):
|
|||||||
return self.renderJsonResponse(self.request, data=data, form=self.get_form())
|
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):
|
class SupplierPartDetail(DetailView):
|
||||||
""" Detail view for SupplierPart """
|
""" Detail view for SupplierPart """
|
||||||
model = SupplierPart
|
model = SupplierPart
|
||||||
@ -563,6 +623,7 @@ class SupplierPartEdit(AjaxUpdateView):
|
|||||||
supplier_part = self.get_object()
|
supplier_part = self.get_object()
|
||||||
|
|
||||||
if supplier_part.manufacturer_part:
|
if supplier_part.manufacturer_part:
|
||||||
|
if supplier_part.manufacturer_part.manufacturer:
|
||||||
initials['manufacturer'] = supplier_part.manufacturer_part.manufacturer.id
|
initials['manufacturer'] = supplier_part.manufacturer_part.manufacturer.id
|
||||||
initials['MPN'] = supplier_part.manufacturer_part.MPN
|
initials['MPN'] = supplier_part.manufacturer_part.MPN
|
||||||
|
|
||||||
|
@ -22,9 +22,10 @@ from .models import PurchaseOrder, PurchaseOrderLineItem
|
|||||||
from .models import PurchaseOrderAttachment
|
from .models import PurchaseOrderAttachment
|
||||||
from .serializers import POSerializer, POLineItemSerializer, POAttachmentSerializer
|
from .serializers import POSerializer, POLineItemSerializer, POAttachmentSerializer
|
||||||
|
|
||||||
from .models import SalesOrder, SalesOrderLineItem
|
from .models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation
|
||||||
from .models import SalesOrderAttachment
|
from .models import SalesOrderAttachment
|
||||||
from .serializers import SalesOrderSerializer, SOLineItemSerializer, SOAttachmentSerializer
|
from .serializers import SalesOrderSerializer, SOLineItemSerializer, SOAttachmentSerializer
|
||||||
|
from .serializers import SalesOrderAllocationSerializer
|
||||||
|
|
||||||
|
|
||||||
class POList(generics.ListCreateAPIView):
|
class POList(generics.ListCreateAPIView):
|
||||||
@ -156,7 +157,7 @@ class POList(generics.ListCreateAPIView):
|
|||||||
ordering = '-creation_date'
|
ordering = '-creation_date'
|
||||||
|
|
||||||
|
|
||||||
class PODetail(generics.RetrieveUpdateAPIView):
|
class PODetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
""" API endpoint for detail view of a PurchaseOrder object """
|
""" API endpoint for detail view of a PurchaseOrder object """
|
||||||
|
|
||||||
queryset = PurchaseOrder.objects.all()
|
queryset = PurchaseOrder.objects.all()
|
||||||
@ -381,7 +382,7 @@ class SOList(generics.ListCreateAPIView):
|
|||||||
ordering = '-creation_date'
|
ordering = '-creation_date'
|
||||||
|
|
||||||
|
|
||||||
class SODetail(generics.RetrieveUpdateAPIView):
|
class SODetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
"""
|
"""
|
||||||
API endpoint for detail view of a SalesOrder object.
|
API endpoint for detail view of a SalesOrder object.
|
||||||
"""
|
"""
|
||||||
@ -422,17 +423,11 @@ class SOLineItemList(generics.ListCreateAPIView):
|
|||||||
def get_serializer(self, *args, **kwargs):
|
def get_serializer(self, *args, **kwargs):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', False))
|
params = self.request.query_params
|
||||||
except AttributeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
kwargs['part_detail'] = str2bool(params.get('part_detail', False))
|
||||||
kwargs['order_detail'] = str2bool(self.request.query_params.get('order_detail', False))
|
kwargs['order_detail'] = str2bool(params.get('order_detail', False))
|
||||||
except AttributeError:
|
kwargs['allocations'] = str2bool(params.get('allocations', False))
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
kwargs['allocations'] = str2bool(self.request.query_params.get('allocations', False))
|
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -486,6 +481,70 @@ class SOLineItemDetail(generics.RetrieveUpdateAPIView):
|
|||||||
serializer_class = SOLineItemSerializer
|
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):
|
class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
||||||
"""
|
"""
|
||||||
API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload)
|
API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload)
|
||||||
@ -494,10 +553,6 @@ class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
|||||||
queryset = PurchaseOrderAttachment.objects.all()
|
queryset = PurchaseOrderAttachment.objects.all()
|
||||||
serializer_class = POAttachmentSerializer
|
serializer_class = POAttachmentSerializer
|
||||||
|
|
||||||
filter_fields = [
|
|
||||||
'order',
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
order_api_urls = [
|
order_api_urls = [
|
||||||
# API endpoints for purchase orders
|
# API endpoints for purchase orders
|
||||||
@ -512,14 +567,26 @@ order_api_urls = [
|
|||||||
url(r'^po-line/$', POLineItemList.as_view(), name='api-po-line-list'),
|
url(r'^po-line/$', POLineItemList.as_view(), name='api-po-line-list'),
|
||||||
|
|
||||||
# API endpoints for sales ordesr
|
# API endpoints for sales ordesr
|
||||||
url(r'^so/(?P<pk>\d+)/$', SODetail.as_view(), name='api-so-detail'),
|
url(r'^so/', include([
|
||||||
url(r'so/attachment/', include([
|
url(r'^(?P<pk>\d+)/$', SODetail.as_view(), name='api-so-detail'),
|
||||||
|
url(r'attachment/', include([
|
||||||
url(r'^.*$', SOAttachmentList.as_view(), name='api-so-attachment-list'),
|
url(r'^.*$', SOAttachmentList.as_view(), name='api-so-attachment-list'),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
url(r'^so/.*$', SOList.as_view(), name='api-so-list'),
|
# List all sales orders
|
||||||
|
url(r'^.*$', SOList.as_view(), name='api-so-list'),
|
||||||
|
])),
|
||||||
|
|
||||||
# API endpoints for sales order line items
|
# API endpoints for sales order line items
|
||||||
url(r'^so-line/(?P<pk>\d+)/$', SOLineItemDetail.as_view(), name='api-so-line-detail'),
|
url(r'^so-line/', include([
|
||||||
url(r'^so-line/$', SOLineItemList.as_view(), name='api-so-line-list'),
|
url(r'^(?P<pk>\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'),
|
||||||
|
])),
|
||||||
]
|
]
|
||||||
|
@ -68,6 +68,7 @@
|
|||||||
order: 1
|
order: 1
|
||||||
part: 1
|
part: 1
|
||||||
quantity: 100
|
quantity: 100
|
||||||
|
destination: 5 # Desk/Drawer_1
|
||||||
|
|
||||||
# 250 x ACME0002 (M2x4 LPHS)
|
# 250 x ACME0002 (M2x4 LPHS)
|
||||||
# Partially received (50)
|
# Partially received (50)
|
||||||
@ -95,3 +96,10 @@
|
|||||||
part: 3
|
part: 3
|
||||||
quantity: 100
|
quantity: 100
|
||||||
|
|
||||||
|
# 1 x R_4K7_0603
|
||||||
|
- model: order.purchaseorderlineitem
|
||||||
|
pk: 23
|
||||||
|
fields:
|
||||||
|
order: 1
|
||||||
|
part: 5
|
||||||
|
quantity: 1
|
||||||
|
@ -11,9 +11,13 @@ from django.utils.translation import ugettext_lazy as _
|
|||||||
from mptt.fields import TreeNodeChoiceField
|
from mptt.fields import TreeNodeChoiceField
|
||||||
|
|
||||||
from InvenTree.forms import HelperForm
|
from InvenTree.forms import HelperForm
|
||||||
from InvenTree.fields import RoundingDecimalFormField
|
from InvenTree.fields import InvenTreeMoneyField, RoundingDecimalFormField
|
||||||
from InvenTree.fields import DatePickerFormField
|
from InvenTree.fields import DatePickerFormField
|
||||||
|
|
||||||
|
from InvenTree.helpers import clean_decimal
|
||||||
|
|
||||||
|
from common.forms import MatchItemForm
|
||||||
|
|
||||||
import part.models
|
import part.models
|
||||||
|
|
||||||
from stock.models import StockLocation
|
from stock.models import StockLocation
|
||||||
@ -79,12 +83,17 @@ class ShipSalesOrderForm(HelperForm):
|
|||||||
|
|
||||||
class ReceivePurchaseOrderForm(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:
|
class Meta:
|
||||||
model = PurchaseOrder
|
model = PurchaseOrder
|
||||||
fields = [
|
fields = [
|
||||||
'location',
|
"location",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -195,6 +204,7 @@ class EditPurchaseOrderLineItemForm(HelperForm):
|
|||||||
'quantity',
|
'quantity',
|
||||||
'reference',
|
'reference',
|
||||||
'purchase_price',
|
'purchase_price',
|
||||||
|
'destination',
|
||||||
'notes',
|
'notes',
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -285,3 +295,36 @@ class EditSalesOrderAllocationForm(HelperForm):
|
|||||||
'line',
|
'line',
|
||||||
'item',
|
'item',
|
||||||
'quantity']
|
'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)
|
||||||
|
@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
35
InvenTree/order/migrations/0047_auto_20210701_0509.py
Normal file
35
InvenTree/order/migrations/0047_auto_20210701_0509.py
Normal file
@ -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),
|
||||||
|
),
|
||||||
|
]
|
@ -17,18 +17,15 @@ from django.contrib.auth.models import User
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
from common.settings import currency_code_default
|
|
||||||
|
|
||||||
from markdownx.models import MarkdownxField
|
from markdownx.models import MarkdownxField
|
||||||
|
from mptt.models import TreeForeignKey
|
||||||
from djmoney.models.fields import MoneyField
|
|
||||||
|
|
||||||
from users import models as UserModels
|
from users import models as UserModels
|
||||||
from part import models as PartModels
|
from part import models as PartModels
|
||||||
from stock import models as stock_models
|
from stock import models as stock_models
|
||||||
from company.models import Company, SupplierPart
|
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.helpers import decimal2string, increment, getSetting
|
||||||
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus, StockHistoryCode
|
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus, StockHistoryCode
|
||||||
from InvenTree.models import InvenTreeAttachment
|
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'))
|
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,
|
max_digits=19,
|
||||||
decimal_places=4,
|
decimal_places=4,
|
||||||
default_currency=currency_code_default(),
|
|
||||||
null=True, blank=True,
|
null=True, blank=True,
|
||||||
verbose_name=_('Purchase Price'),
|
verbose_name=_('Purchase Price'),
|
||||||
help_text=_('Unit 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):
|
def remaining(self):
|
||||||
""" Calculate the number of items remaining to be received """
|
""" Calculate the number of items remaining to be received """
|
||||||
r = self.quantity - self.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})
|
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,
|
max_digits=19,
|
||||||
decimal_places=4,
|
decimal_places=4,
|
||||||
default_currency=currency_code_default(),
|
|
||||||
null=True, blank=True,
|
null=True, blank=True,
|
||||||
verbose_name=_('Sale Price'),
|
verbose_name=_('Sale Price'),
|
||||||
help_text=_('Unit sale price'),
|
help_text=_('Unit sale price'),
|
||||||
|
@ -17,6 +17,7 @@ from InvenTree.serializers import InvenTreeAttachmentSerializerField
|
|||||||
|
|
||||||
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer
|
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer
|
||||||
from part.serializers import PartBriefSerializer
|
from part.serializers import PartBriefSerializer
|
||||||
|
from stock.serializers import LocationBriefSerializer, StockItemSerializer, LocationSerializer
|
||||||
|
|
||||||
from .models import PurchaseOrder, PurchaseOrderLineItem
|
from .models import PurchaseOrder, PurchaseOrderLineItem
|
||||||
from .models import PurchaseOrderAttachment, SalesOrderAttachment
|
from .models import PurchaseOrderAttachment, SalesOrderAttachment
|
||||||
@ -41,7 +42,7 @@ class POSerializer(InvenTreeModelSerializer):
|
|||||||
"""
|
"""
|
||||||
Add extra information to the queryset
|
Add extra information to the queryset
|
||||||
|
|
||||||
- Number of liens in the PurchaseOrder
|
- Number of lines in the PurchaseOrder
|
||||||
- Overdue status of the PurchaseOrder
|
- Overdue status of the PurchaseOrder
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -91,8 +92,10 @@ class POSerializer(InvenTreeModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
'reference',
|
|
||||||
'status'
|
'status'
|
||||||
|
'issue_date',
|
||||||
|
'complete_date',
|
||||||
|
'creation_date',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -108,14 +111,17 @@ class POLineItemSerializer(InvenTreeModelSerializer):
|
|||||||
self.fields.pop('part_detail')
|
self.fields.pop('part_detail')
|
||||||
self.fields.pop('supplier_part_detail')
|
self.fields.pop('supplier_part_detail')
|
||||||
|
|
||||||
quantity = serializers.FloatField()
|
# TODO: Once https://github.com/inventree/InvenTree/issues/1687 is fixed, remove default values
|
||||||
received = serializers.FloatField()
|
quantity = serializers.FloatField(default=1)
|
||||||
|
received = serializers.FloatField(default=0)
|
||||||
|
|
||||||
part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True)
|
part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True)
|
||||||
supplier_part_detail = SupplierPartSerializer(source='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)
|
purchase_price_string = serializers.CharField(source='purchase_price', read_only=True)
|
||||||
|
|
||||||
|
destination = LocationBriefSerializer(source='get_destination', read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = PurchaseOrderLineItem
|
model = PurchaseOrderLineItem
|
||||||
|
|
||||||
@ -132,6 +138,7 @@ class POLineItemSerializer(InvenTreeModelSerializer):
|
|||||||
'purchase_price',
|
'purchase_price',
|
||||||
'purchase_price_currency',
|
'purchase_price_currency',
|
||||||
'purchase_price_string',
|
'purchase_price_string',
|
||||||
|
'destination',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -221,8 +228,9 @@ class SalesOrderSerializer(InvenTreeModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
read_only_fields = [
|
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.
|
This includes some fields from the related model objects.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
location_path = serializers.CharField(source='get_location_path')
|
part = serializers.PrimaryKeyRelatedField(source='item.part', read_only=True)
|
||||||
location_id = serializers.IntegerField(source='get_location')
|
order = serializers.PrimaryKeyRelatedField(source='line.order', many=False, read_only=True)
|
||||||
serial = serializers.CharField(source='get_serial')
|
serial = serializers.CharField(source='get_serial', read_only=True)
|
||||||
po = serializers.CharField(source='get_po')
|
quantity = serializers.FloatField(read_only=True)
|
||||||
quantity = serializers.FloatField()
|
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:
|
class Meta:
|
||||||
model = SalesOrderAllocation
|
model = SalesOrderAllocation
|
||||||
@ -246,10 +281,14 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
|
|||||||
'line',
|
'line',
|
||||||
'serial',
|
'serial',
|
||||||
'quantity',
|
'quantity',
|
||||||
'location_id',
|
'location',
|
||||||
'location_path',
|
'location_detail',
|
||||||
'po',
|
|
||||||
'item',
|
'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)
|
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
||||||
allocations = SalesOrderAllocationSerializer(many=True, 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)
|
allocated = serializers.FloatField(source='allocated_quantity', read_only=True)
|
||||||
fulfilled = serializers.FloatField(source='fulfilled_quantity', read_only=True)
|
fulfilled = serializers.FloatField(source='fulfilled_quantity', read_only=True)
|
||||||
sale_price_string = serializers.CharField(source='sale_price', read_only=True)
|
sale_price_string = serializers.CharField(source='sale_price', read_only=True)
|
||||||
|
@ -75,7 +75,7 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
<tr>
|
<tr>
|
||||||
<td><span class='fas fa-hashtag'></span></td>
|
<td><span class='fas fa-hashtag'></span></td>
|
||||||
<td>{% trans "Order Reference" %}</td>
|
<td>{% trans "Order Reference" %}</td>
|
||||||
<td>{{ order.reference }}{% include "clip.html"%}</td>
|
<td>{% settings_value 'PURCHASEORDER_REFERENCE_PREFIX' %}{{ order.reference }}{% include "clip.html"%}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class='fas fa-info'></span></td>
|
<td><span class='fas fa-info'></span></td>
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
{% load inventree_extras %}
|
{% load inventree_extras %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
{% block form_alert %}
|
{% block form_alert %}
|
||||||
{% if form.errors %}
|
{% if form.errors %}
|
||||||
@ -67,7 +68,7 @@
|
|||||||
<td>
|
<td>
|
||||||
{% for field in form.visible_fields %}
|
{% for field in form.visible_fields %}
|
||||||
{% if field.name == row.quantity %}
|
{% if field.name == row.quantity %}
|
||||||
{{ field }}
|
{{ field|as_crispy_field }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if row.errors.quantity %}
|
{% if row.errors.quantity %}
|
||||||
@ -80,19 +81,19 @@
|
|||||||
{% if item.column.guess == 'Purchase_Price' %}
|
{% if item.column.guess == 'Purchase_Price' %}
|
||||||
{% for field in form.visible_fields %}
|
{% for field in form.visible_fields %}
|
||||||
{% if field.name == row.purchase_price %}
|
{% if field.name == row.purchase_price %}
|
||||||
{{ field }}
|
{{ field|as_crispy_field }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% elif item.column.guess == 'Reference' %}
|
{% elif item.column.guess == 'Reference' %}
|
||||||
{% for field in form.visible_fields %}
|
{% for field in form.visible_fields %}
|
||||||
{% if field.name == row.reference %}
|
{% if field.name == row.reference %}
|
||||||
{{ field }}
|
{{ field|as_crispy_field }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% elif item.column.guess == 'Notes' %}
|
{% elif item.column.guess == 'Notes' %}
|
||||||
{% for field in form.visible_fields %}
|
{% for field in form.visible_fields %}
|
||||||
{% if field.name == row.notes %}
|
{% if field.name == row.notes %}
|
||||||
{{ field }}
|
{{ field|as_crispy_field }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
|
@ -4,6 +4,8 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block form %}
|
{% block form %}
|
||||||
|
{% default_currency as currency %}
|
||||||
|
{% settings_value 'PART_SHOW_PRICE_IN_FORMS' as show_price %}
|
||||||
|
|
||||||
<h4>
|
<h4>
|
||||||
{% trans "Step 1 of 2 - Select Part Suppliers" %}
|
{% trans "Step 1 of 2 - Select Part Suppliers" %}
|
||||||
@ -49,7 +51,13 @@
|
|||||||
<select class='select' id='id_supplier_part_{{ part.id }}' name="part-supplier-{{ part.id }}">
|
<select class='select' id='id_supplier_part_{{ part.id }}' name="part-supplier-{{ part.id }}">
|
||||||
<option value=''>---------</option>
|
<option value=''>---------</option>
|
||||||
{% for supplier in part.supplier_parts.all %}
|
{% for supplier in part.supplier_parts.all %}
|
||||||
<option value="{{ supplier.id }}"{% if part.order_supplier == supplier.id %} selected="selected"{% endif %}>{{ supplier }}</option>
|
<option value="{{ supplier.id }}"{% if part.order_supplier == supplier.id %} selected="selected"{% endif %}>
|
||||||
|
{% if show_price %}
|
||||||
|
{% call_method supplier 'get_price' part.order_quantity as price %}
|
||||||
|
{% if price != None %}{% include "price.html" with price=price %}{% else %}{% trans 'No price' %}{% endif %} -
|
||||||
|
{% endif %}
|
||||||
|
{{ supplier }}
|
||||||
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
@ -57,8 +57,6 @@ $("#attachment-table").on('click', '.attachment-delete-button', function() {
|
|||||||
|
|
||||||
var url = `/order/purchase-order/attachment/${button.attr('pk')}/delete/`;
|
var url = `/order/purchase-order/attachment/${button.attr('pk')}/delete/`;
|
||||||
|
|
||||||
console.log("url: " + url);
|
|
||||||
|
|
||||||
launchModalForm(url, {
|
launchModalForm(url, {
|
||||||
reload: true,
|
reload: true,
|
||||||
});
|
});
|
||||||
|
@ -139,7 +139,7 @@ $("#po-table").inventreeTable({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
footerFormatter: function() {
|
footerFormatter: function() {
|
||||||
return 'Total'
|
return '{% trans "Total" %}'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -234,6 +234,10 @@ $("#po-table").inventreeTable({
|
|||||||
return (progressA < progressB) ? 1 : -1;
|
return (progressA < progressB) ? 1 : -1;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
field: 'destination.pathstring',
|
||||||
|
title: '{% trans "Destination" %}',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
field: 'notes',
|
field: 'notes',
|
||||||
title: '{% trans "Notes" %}',
|
title: '{% trans "Notes" %}',
|
||||||
|
@ -22,6 +22,7 @@
|
|||||||
<th>{% trans "Received" %}</th>
|
<th>{% trans "Received" %}</th>
|
||||||
<th>{% trans "Receive" %}</th>
|
<th>{% trans "Receive" %}</th>
|
||||||
<th>{% trans "Status" %}</th>
|
<th>{% trans "Status" %}</th>
|
||||||
|
<th>{% trans "Destination" %}</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
{% for line in lines %}
|
{% for line in lines %}
|
||||||
@ -53,6 +54,9 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ line.get_destination }}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button class='btn btn-default btn-remove' onClick="removeOrderRowFromOrderWizard()" id='del_item_{{ line.id }}' title='{% trans "Remove line" %}' type='button'>
|
<button class='btn btn-default btn-remove' onClick="removeOrderRowFromOrderWizard()" id='del_item_{{ line.id }}' title='{% trans "Remove line" %}' type='button'>
|
||||||
<span row='line_row_{{ line.id }}' class='fas fa-times-circle icon-red'></span>
|
<span row='line_row_{{ line.id }}' class='fas fa-times-circle icon-red'></span>
|
||||||
|
@ -77,7 +77,7 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
<tr>
|
<tr>
|
||||||
<td><span class='fas fa-hashtag'></span></td>
|
<td><span class='fas fa-hashtag'></span></td>
|
||||||
<td>{% trans "Order Reference" %}</td>
|
<td>{% trans "Order Reference" %}</td>
|
||||||
<td>{{ order.reference }}{% include "clip.html"%}</td>
|
<td>{% settings_value 'SALESORDER_REFERENCE_PREFIX' %}{{ order.reference }}{% include "clip.html"%}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class='fas fa-info'></span></td>
|
<td><span class='fas fa-info'></span></td>
|
||||||
|
@ -81,10 +81,10 @@ function showAllocationSubTable(index, row, element) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'location_id',
|
field: 'location',
|
||||||
title: 'Location',
|
title: 'Location',
|
||||||
formatter: function(value, row, index, field) {
|
formatter: function(value, row, index, field) {
|
||||||
return renderLink(row.location_path, `/stock/location/${row.location_id}/`);
|
return renderLink(row.location_path, `/stock/location/${row.location}/`);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -199,6 +199,7 @@ $("#so-lines-table").inventreeTable({
|
|||||||
detailFormatter: showFulfilledSubTable,
|
detailFormatter: showFulfilledSubTable,
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
showFooter: true,
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
field: 'pk',
|
field: 'pk',
|
||||||
@ -217,7 +218,10 @@ $("#so-lines-table").inventreeTable({
|
|||||||
} else {
|
} else {
|
||||||
return '-';
|
return '-';
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
footerFormatter: function() {
|
||||||
|
return '{% trans "Total" %}'
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sortable: true,
|
sortable: true,
|
||||||
@ -228,6 +232,13 @@ $("#so-lines-table").inventreeTable({
|
|||||||
sortable: true,
|
sortable: true,
|
||||||
field: 'quantity',
|
field: 'quantity',
|
||||||
title: '{% trans "Quantity" %}',
|
title: '{% trans "Quantity" %}',
|
||||||
|
footerFormatter: function(data) {
|
||||||
|
return data.map(function (row) {
|
||||||
|
return +row['quantity']
|
||||||
|
}).reduce(function (sum, i) {
|
||||||
|
return sum + i
|
||||||
|
}, 0)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sortable: true,
|
sortable: true,
|
||||||
@ -237,6 +248,26 @@ $("#so-lines-table").inventreeTable({
|
|||||||
return row.sale_price_string || row.sale_price;
|
return row.sale_price_string || row.sale_price;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
sortable: true,
|
||||||
|
title: '{% trans "Total price" %}',
|
||||||
|
formatter: function(value, row) {
|
||||||
|
var total = row.sale_price * row.quantity;
|
||||||
|
var formatter = new Intl.NumberFormat('en-US', {style: 'currency', currency: row.sale_price_currency});
|
||||||
|
return formatter.format(total)
|
||||||
|
},
|
||||||
|
footerFormatter: function(data) {
|
||||||
|
var total = data.map(function (row) {
|
||||||
|
return +row['sale_price']*row['quantity']
|
||||||
|
}).reduce(function (sum, i) {
|
||||||
|
return sum + i
|
||||||
|
}, 0)
|
||||||
|
var currency = (data.slice(-1)[0] && data.slice(-1)[0].sale_price_currency) || 'USD';
|
||||||
|
var formatter = new Intl.NumberFormat('en-US', {style: 'currency', currency: currency});
|
||||||
|
return formatter.format(total)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
field: 'allocated',
|
field: 'allocated',
|
||||||
{% if order.status == SalesOrderStatus.PENDING %}
|
{% if order.status == SalesOrderStatus.PENDING %}
|
||||||
|
@ -110,6 +110,96 @@ class PurchaseOrderTest(OrderTest):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def test_po_operations(self):
|
||||||
|
"""
|
||||||
|
Test that we can create / edit and delete a PurchaseOrder via the API
|
||||||
|
"""
|
||||||
|
|
||||||
|
n = PurchaseOrder.objects.count()
|
||||||
|
|
||||||
|
url = reverse('api-po-list')
|
||||||
|
|
||||||
|
# Initially we do not have "add" permission for the PurchaseOrder model,
|
||||||
|
# so this POST request should return 403
|
||||||
|
response = self.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'supplier': 1,
|
||||||
|
'reference': '123456789-xyz',
|
||||||
|
'description': 'PO created via the API',
|
||||||
|
},
|
||||||
|
expected_code=403
|
||||||
|
)
|
||||||
|
|
||||||
|
# And no new PurchaseOrder objects should have been created
|
||||||
|
self.assertEqual(PurchaseOrder.objects.count(), n)
|
||||||
|
|
||||||
|
# Ok, now let's give this user the correct permission
|
||||||
|
self.assignRole('purchase_order.add')
|
||||||
|
|
||||||
|
# Initially we do not have "add" permission for the PurchaseOrder model,
|
||||||
|
# so this POST request should return 403
|
||||||
|
response = self.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'supplier': 1,
|
||||||
|
'reference': '123456789-xyz',
|
||||||
|
'description': 'PO created via the API',
|
||||||
|
},
|
||||||
|
expected_code=201
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(PurchaseOrder.objects.count(), n + 1)
|
||||||
|
|
||||||
|
pk = response.data['pk']
|
||||||
|
|
||||||
|
# Try to create a PO with identical reference (should fail!)
|
||||||
|
response = self.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'supplier': 1,
|
||||||
|
'reference': '123456789-xyz',
|
||||||
|
'description': 'A different description',
|
||||||
|
},
|
||||||
|
expected_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(PurchaseOrder.objects.count(), n + 1)
|
||||||
|
|
||||||
|
url = reverse('api-po-detail', kwargs={'pk': pk})
|
||||||
|
|
||||||
|
# Get detail info!
|
||||||
|
response = self.get(url)
|
||||||
|
self.assertEqual(response.data['pk'], pk)
|
||||||
|
self.assertEqual(response.data['reference'], '123456789-xyz')
|
||||||
|
|
||||||
|
# Try to alter (edit) the PurchaseOrder
|
||||||
|
response = self.patch(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'reference': '12345-abc',
|
||||||
|
},
|
||||||
|
expected_code=200
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reference should have changed
|
||||||
|
self.assertEqual(response.data['reference'], '12345-abc')
|
||||||
|
|
||||||
|
# Now, let's try to delete it!
|
||||||
|
# Initially, we do *not* have the required permission!
|
||||||
|
response = self.delete(url, expected_code=403)
|
||||||
|
|
||||||
|
# Now, add the "delete" permission!
|
||||||
|
self.assignRole("purchase_order.delete")
|
||||||
|
|
||||||
|
response = self.delete(url, expected_code=204)
|
||||||
|
|
||||||
|
# Number of PurchaseOrder objects should have decreased
|
||||||
|
self.assertEqual(PurchaseOrder.objects.count(), n)
|
||||||
|
|
||||||
|
# And if we try to access the detail view again, it has gone
|
||||||
|
response = self.get(url, expected_code=404)
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderTest(OrderTest):
|
class SalesOrderTest(OrderTest):
|
||||||
"""
|
"""
|
||||||
@ -158,8 +248,6 @@ class SalesOrderTest(OrderTest):
|
|||||||
|
|
||||||
response = self.get(url)
|
response = self.get(url)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
data = response.data
|
data = response.data
|
||||||
|
|
||||||
self.assertEqual(data['pk'], 1)
|
self.assertEqual(data['pk'], 1)
|
||||||
@ -168,6 +256,87 @@ class SalesOrderTest(OrderTest):
|
|||||||
|
|
||||||
url = reverse('api-so-attachment-list')
|
url = reverse('api-so-attachment-list')
|
||||||
|
|
||||||
response = self.get(url)
|
self.get(url)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
def test_so_operations(self):
|
||||||
|
"""
|
||||||
|
Test that we can create / edit and delete a SalesOrder via the API
|
||||||
|
"""
|
||||||
|
|
||||||
|
n = SalesOrder.objects.count()
|
||||||
|
|
||||||
|
url = reverse('api-so-list')
|
||||||
|
|
||||||
|
# Initially we do not have "add" permission for the SalesOrder model,
|
||||||
|
# so this POST request should return 403 (denied)
|
||||||
|
response = self.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'customer': 4,
|
||||||
|
'reference': '12345',
|
||||||
|
'description': 'Sales order',
|
||||||
|
},
|
||||||
|
expected_code=403,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assignRole('sales_order.add')
|
||||||
|
|
||||||
|
# Now we should be able to create a SalesOrder via the API
|
||||||
|
response = self.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'customer': 4,
|
||||||
|
'reference': '12345',
|
||||||
|
'description': 'Sales order',
|
||||||
|
},
|
||||||
|
expected_code=201
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check that the new order has been created
|
||||||
|
self.assertEqual(SalesOrder.objects.count(), n + 1)
|
||||||
|
|
||||||
|
# Grab the PK for the newly created SalesOrder
|
||||||
|
pk = response.data['pk']
|
||||||
|
|
||||||
|
# Try to create a SO with identical reference (should fail)
|
||||||
|
response = self.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'customer': 4,
|
||||||
|
'reference': '12345',
|
||||||
|
'description': 'Another sales order',
|
||||||
|
},
|
||||||
|
expected_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
url = reverse('api-so-detail', kwargs={'pk': pk})
|
||||||
|
|
||||||
|
# Extract detail info for the SalesOrder
|
||||||
|
response = self.get(url)
|
||||||
|
self.assertEqual(response.data['reference'], '12345')
|
||||||
|
|
||||||
|
# Try to alter (edit) the SalesOrder
|
||||||
|
response = self.patch(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'reference': '12345-a',
|
||||||
|
},
|
||||||
|
expected_code=200
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reference should have changed
|
||||||
|
self.assertEqual(response.data['reference'], '12345-a')
|
||||||
|
|
||||||
|
# Now, let's try to delete this SalesOrder
|
||||||
|
# Initially, we do not have the required permission
|
||||||
|
response = self.delete(url, expected_code=403)
|
||||||
|
|
||||||
|
self.assignRole('sales_order.delete')
|
||||||
|
|
||||||
|
response = self.delete(url, expected_code=204)
|
||||||
|
|
||||||
|
# Check that the number of sales orders has decreased
|
||||||
|
self.assertEqual(SalesOrder.objects.count(), n)
|
||||||
|
|
||||||
|
# And the resource should no longer be available
|
||||||
|
response = self.get(url, expected_code=404)
|
||||||
|
@ -87,7 +87,7 @@ class OrderTest(TestCase):
|
|||||||
order = PurchaseOrder.objects.get(pk=1)
|
order = PurchaseOrder.objects.get(pk=1)
|
||||||
|
|
||||||
self.assertEqual(order.status, PurchaseOrderStatus.PENDING)
|
self.assertEqual(order.status, PurchaseOrderStatus.PENDING)
|
||||||
self.assertEqual(order.lines.count(), 3)
|
self.assertEqual(order.lines.count(), 4)
|
||||||
|
|
||||||
sku = SupplierPart.objects.get(SKU='ACME-WIDGET')
|
sku = SupplierPart.objects.get(SKU='ACME-WIDGET')
|
||||||
part = sku.part
|
part = sku.part
|
||||||
@ -105,11 +105,11 @@ class OrderTest(TestCase):
|
|||||||
order.add_line_item(sku, 100)
|
order.add_line_item(sku, 100)
|
||||||
|
|
||||||
self.assertEqual(part.on_order, 100)
|
self.assertEqual(part.on_order, 100)
|
||||||
self.assertEqual(order.lines.count(), 4)
|
self.assertEqual(order.lines.count(), 5)
|
||||||
|
|
||||||
# Order the same part again (it should be merged)
|
# Order the same part again (it should be merged)
|
||||||
order.add_line_item(sku, 50)
|
order.add_line_item(sku, 50)
|
||||||
self.assertEqual(order.lines.count(), 4)
|
self.assertEqual(order.lines.count(), 5)
|
||||||
self.assertEqual(part.on_order, 150)
|
self.assertEqual(part.on_order, 150)
|
||||||
|
|
||||||
# Try to order a supplier part from the wrong supplier
|
# Try to order a supplier part from the wrong supplier
|
||||||
@ -163,7 +163,7 @@ class OrderTest(TestCase):
|
|||||||
loc = StockLocation.objects.get(id=1)
|
loc = StockLocation.objects.get(id=1)
|
||||||
|
|
||||||
# There should be two lines against this order
|
# There should be two lines against this order
|
||||||
self.assertEqual(len(order.pending_line_items()), 3)
|
self.assertEqual(len(order.pending_line_items()), 4)
|
||||||
|
|
||||||
# Should fail, as order is 'PENDING' not 'PLACED"
|
# Should fail, as order is 'PENDING' not 'PLACED"
|
||||||
self.assertEqual(order.status, PurchaseOrderStatus.PENDING)
|
self.assertEqual(order.status, PurchaseOrderStatus.PENDING)
|
||||||
|
@ -30,7 +30,9 @@ from stock.models import StockItem, StockLocation
|
|||||||
from part.models import Part
|
from part.models import Part
|
||||||
|
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
|
from common.forms import UploadFileForm, MatchFieldForm
|
||||||
from common.views import FileManagementFormView
|
from common.views import FileManagementFormView
|
||||||
|
from common.files import FileManager
|
||||||
|
|
||||||
from . import forms as order_forms
|
from . import forms as order_forms
|
||||||
from part.views import PartPricing
|
from part.views import PartPricing
|
||||||
@ -572,7 +574,28 @@ class SalesOrderShip(AjaxUpdateView):
|
|||||||
class PurchaseOrderUpload(FileManagementFormView):
|
class PurchaseOrderUpload(FileManagementFormView):
|
||||||
''' PurchaseOrder: Upload file, match to fields and parts (using multi-Step form) '''
|
''' PurchaseOrder: Upload file, match to fields and parts (using multi-Step form) '''
|
||||||
|
|
||||||
|
class OrderFileManager(FileManager):
|
||||||
|
REQUIRED_HEADERS = [
|
||||||
|
'Quantity',
|
||||||
|
]
|
||||||
|
|
||||||
|
ITEM_MATCH_HEADERS = [
|
||||||
|
'Manufacturer_MPN',
|
||||||
|
'Supplier_SKU',
|
||||||
|
]
|
||||||
|
|
||||||
|
OPTIONAL_HEADERS = [
|
||||||
|
'Purchase_Price',
|
||||||
|
'Reference',
|
||||||
|
'Notes',
|
||||||
|
]
|
||||||
|
|
||||||
name = 'order'
|
name = 'order'
|
||||||
|
form_list = [
|
||||||
|
('upload', UploadFileForm),
|
||||||
|
('fields', MatchFieldForm),
|
||||||
|
('items', order_forms.OrderMatchItemForm),
|
||||||
|
]
|
||||||
form_steps_template = [
|
form_steps_template = [
|
||||||
'order/order_wizard/po_upload.html',
|
'order/order_wizard/po_upload.html',
|
||||||
'order/order_wizard/match_fields.html',
|
'order/order_wizard/match_fields.html',
|
||||||
@ -583,7 +606,6 @@ class PurchaseOrderUpload(FileManagementFormView):
|
|||||||
_("Match Fields"),
|
_("Match Fields"),
|
||||||
_("Match Supplier Parts"),
|
_("Match Supplier Parts"),
|
||||||
]
|
]
|
||||||
# Form field name: PurchaseOrderLineItem field
|
|
||||||
form_field_map = {
|
form_field_map = {
|
||||||
'item_select': 'part',
|
'item_select': 'part',
|
||||||
'quantity': 'quantity',
|
'quantity': 'quantity',
|
||||||
@ -591,6 +613,7 @@ class PurchaseOrderUpload(FileManagementFormView):
|
|||||||
'reference': 'reference',
|
'reference': 'reference',
|
||||||
'notes': 'notes',
|
'notes': 'notes',
|
||||||
}
|
}
|
||||||
|
file_manager_class = OrderFileManager
|
||||||
|
|
||||||
def get_order(self):
|
def get_order(self):
|
||||||
""" Get order or return 404 """
|
""" Get order or return 404 """
|
||||||
@ -598,6 +621,8 @@ class PurchaseOrderUpload(FileManagementFormView):
|
|||||||
return get_object_or_404(PurchaseOrder, pk=self.kwargs['pk'])
|
return get_object_or_404(PurchaseOrder, pk=self.kwargs['pk'])
|
||||||
|
|
||||||
def get_context_data(self, form, **kwargs):
|
def get_context_data(self, form, **kwargs):
|
||||||
|
""" Handle context data for order """
|
||||||
|
|
||||||
context = super().get_context_data(form=form, **kwargs)
|
context = super().get_context_data(form=form, **kwargs)
|
||||||
|
|
||||||
order = self.get_order()
|
order = self.get_order()
|
||||||
@ -708,26 +733,7 @@ class PurchaseOrderUpload(FileManagementFormView):
|
|||||||
""" Once all the data is in, process it to add PurchaseOrderLineItem instances to the order """
|
""" Once all the data is in, process it to add PurchaseOrderLineItem instances to the order """
|
||||||
|
|
||||||
order = self.get_order()
|
order = self.get_order()
|
||||||
|
items = self.get_clean_items()
|
||||||
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
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
# Create PurchaseOrderLineItem instances
|
# Create PurchaseOrderLineItem instances
|
||||||
for purchase_order_item in items.values():
|
for purchase_order_item in items.values():
|
||||||
@ -1004,6 +1010,15 @@ class OrderParts(AjaxView):
|
|||||||
|
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
def get_data(self):
|
||||||
|
""" enrich respone json data """
|
||||||
|
data = super().get_data()
|
||||||
|
# if in selection-phase, add a button to update the prices
|
||||||
|
if getattr(self, 'form_step', 'select_parts') == 'select_parts':
|
||||||
|
data['buttons'] = [{'name': 'update_price', 'title': _('Update prices')}] # set buttons
|
||||||
|
data['hideErrorMessage'] = '1' # hide the error message
|
||||||
|
return data
|
||||||
|
|
||||||
def get_suppliers(self):
|
def get_suppliers(self):
|
||||||
""" Calculates a list of suppliers which the user will need to create POs for.
|
""" Calculates a list of suppliers which the user will need to create POs for.
|
||||||
This is calculated AFTER the user finishes selecting the parts to order.
|
This is calculated AFTER the user finishes selecting the parts to order.
|
||||||
@ -1238,9 +1253,10 @@ class OrderParts(AjaxView):
|
|||||||
valid = False
|
valid = False
|
||||||
|
|
||||||
if form_step == 'select_parts':
|
if form_step == 'select_parts':
|
||||||
# No errors? Proceed to PO selection form
|
# No errors? and the price-update button was not used to submit? Proceed to PO selection form
|
||||||
if part_errors is False:
|
if part_errors is False and 'act-btn_update_price' not in request.POST:
|
||||||
self.ajax_template_name = 'order/order_wizard/select_pos.html'
|
self.ajax_template_name = 'order/order_wizard/select_pos.html'
|
||||||
|
self.form_step = 'select_purchase_orders' # set step (important for get_data)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
self.ajax_template_name = 'order/order_wizard/select_parts.html'
|
self.ajax_template_name = 'order/order_wizard/select_parts.html'
|
||||||
|
@ -14,7 +14,7 @@ from .models import BomItem
|
|||||||
from .models import PartParameterTemplate, PartParameter
|
from .models import PartParameterTemplate, PartParameter
|
||||||
from .models import PartCategoryParameterTemplate
|
from .models import PartCategoryParameterTemplate
|
||||||
from .models import PartTestTemplate
|
from .models import PartTestTemplate
|
||||||
from .models import PartSellPriceBreak
|
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||||
|
|
||||||
from stock.models import StockLocation
|
from stock.models import StockLocation
|
||||||
from company.models import SupplierPart
|
from company.models import SupplierPart
|
||||||
@ -111,6 +111,13 @@ class PartCategoryResource(ModelResource):
|
|||||||
PartCategory.objects.rebuild()
|
PartCategory.objects.rebuild()
|
||||||
|
|
||||||
|
|
||||||
|
class PartCategoryInline(admin.TabularInline):
|
||||||
|
"""
|
||||||
|
Inline for PartCategory model
|
||||||
|
"""
|
||||||
|
model = PartCategory
|
||||||
|
|
||||||
|
|
||||||
class PartCategoryAdmin(ImportExportModelAdmin):
|
class PartCategoryAdmin(ImportExportModelAdmin):
|
||||||
|
|
||||||
resource_class = PartCategoryResource
|
resource_class = PartCategoryResource
|
||||||
@ -119,6 +126,10 @@ class PartCategoryAdmin(ImportExportModelAdmin):
|
|||||||
|
|
||||||
search_fields = ('name', 'description')
|
search_fields = ('name', 'description')
|
||||||
|
|
||||||
|
inlines = [
|
||||||
|
PartCategoryInline,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class PartRelatedAdmin(admin.ModelAdmin):
|
class PartRelatedAdmin(admin.ModelAdmin):
|
||||||
''' Class to manage PartRelated objects '''
|
''' Class to manage PartRelated objects '''
|
||||||
@ -286,6 +297,14 @@ class PartSellPriceBreakAdmin(admin.ModelAdmin):
|
|||||||
list_display = ('part', 'quantity', 'price',)
|
list_display = ('part', 'quantity', 'price',)
|
||||||
|
|
||||||
|
|
||||||
|
class PartInternalPriceBreakAdmin(admin.ModelAdmin):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = PartInternalPriceBreak
|
||||||
|
|
||||||
|
list_display = ('part', 'quantity', 'price',)
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(Part, PartAdmin)
|
admin.site.register(Part, PartAdmin)
|
||||||
admin.site.register(PartCategory, PartCategoryAdmin)
|
admin.site.register(PartCategory, PartCategoryAdmin)
|
||||||
admin.site.register(PartRelated, PartRelatedAdmin)
|
admin.site.register(PartRelated, PartRelatedAdmin)
|
||||||
@ -297,3 +316,4 @@ admin.site.register(PartParameter, ParameterAdmin)
|
|||||||
admin.site.register(PartCategoryParameterTemplate, PartCategoryParameterAdmin)
|
admin.site.register(PartCategoryParameterTemplate, PartCategoryParameterAdmin)
|
||||||
admin.site.register(PartTestTemplate, PartTestTemplateAdmin)
|
admin.site.register(PartTestTemplate, PartTestTemplateAdmin)
|
||||||
admin.site.register(PartSellPriceBreak, PartSellPriceBreakAdmin)
|
admin.site.register(PartSellPriceBreak, PartSellPriceBreakAdmin)
|
||||||
|
admin.site.register(PartInternalPriceBreak, PartInternalPriceBreakAdmin)
|
||||||
|
@ -25,7 +25,7 @@ from django.urls import reverse
|
|||||||
from .models import Part, PartCategory, BomItem
|
from .models import Part, PartCategory, BomItem
|
||||||
from .models import PartParameter, PartParameterTemplate
|
from .models import PartParameter, PartParameterTemplate
|
||||||
from .models import PartAttachment, PartTestTemplate
|
from .models import PartAttachment, PartTestTemplate
|
||||||
from .models import PartSellPriceBreak
|
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||||
from .models import PartCategoryParameterTemplate
|
from .models import PartCategoryParameterTemplate
|
||||||
|
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
@ -194,6 +194,24 @@ class PartSalePriceList(generics.ListCreateAPIView):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class PartInternalPriceList(generics.ListCreateAPIView):
|
||||||
|
"""
|
||||||
|
API endpoint for list view of PartInternalPriceBreak model
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = PartInternalPriceBreak.objects.all()
|
||||||
|
serializer_class = part_serializers.PartInternalPriceSerializer
|
||||||
|
permission_required = 'roles.sales_order.show'
|
||||||
|
|
||||||
|
filter_backends = [
|
||||||
|
DjangoFilterBackend
|
||||||
|
]
|
||||||
|
|
||||||
|
filter_fields = [
|
||||||
|
'part',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class PartAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
class PartAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
||||||
"""
|
"""
|
||||||
API endpoint for listing (and creating) a PartAttachment (file upload).
|
API endpoint for listing (and creating) a PartAttachment (file upload).
|
||||||
@ -688,6 +706,7 @@ class PartList(generics.ListCreateAPIView):
|
|||||||
'creation_date',
|
'creation_date',
|
||||||
'IPN',
|
'IPN',
|
||||||
'in_stock',
|
'in_stock',
|
||||||
|
'category',
|
||||||
]
|
]
|
||||||
|
|
||||||
# Default ordering
|
# Default ordering
|
||||||
@ -1017,6 +1036,11 @@ part_api_urls = [
|
|||||||
url(r'^.*$', PartSalePriceList.as_view(), name='api-part-sale-price-list'),
|
url(r'^.*$', PartSalePriceList.as_view(), name='api-part-sale-price-list'),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
|
# Base URL for part internal pricing
|
||||||
|
url(r'^internal-price/', include([
|
||||||
|
url(r'^.*$', PartInternalPriceList.as_view(), name='api-part-internal-price-list'),
|
||||||
|
])),
|
||||||
|
|
||||||
# Base URL for PartParameter API endpoints
|
# Base URL for PartParameter API endpoints
|
||||||
url(r'^parameter/', include([
|
url(r'^parameter/', include([
|
||||||
url(r'^template/$', PartParameterTemplateList.as_view(), name='api-part-param-template-list'),
|
url(r'^template/$', PartParameterTemplateList.as_view(), name='api-part-param-template-list'),
|
||||||
|
@ -39,7 +39,8 @@ class PartConfig(AppConfig):
|
|||||||
logger.debug("InvenTree: Checking Part image thumbnails")
|
logger.debug("InvenTree: Checking Part image thumbnails")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for part in Part.objects.all():
|
# Only check parts which have images
|
||||||
|
for part in Part.objects.exclude(image=None):
|
||||||
if part.image:
|
if part.image:
|
||||||
url = part.image.thumbnail.name
|
url = part.image.thumbnail.name
|
||||||
loc = os.path.join(settings.MEDIA_ROOT, url)
|
loc = os.path.join(settings.MEDIA_ROOT, url)
|
||||||
@ -50,8 +51,7 @@ class PartConfig(AppConfig):
|
|||||||
part.image.render_variations(replace=False)
|
part.image.render_variations(replace=False)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
logger.warning(f"Image file '{part.image}' missing")
|
logger.warning(f"Image file '{part.image}' missing")
|
||||||
part.image = None
|
pass
|
||||||
part.save()
|
|
||||||
except UnidentifiedImageError:
|
except UnidentifiedImageError:
|
||||||
logger.warning(f"Image file '{part.image}' is invalid")
|
logger.warning(f"Image file '{part.image}' is invalid")
|
||||||
except (OperationalError, ProgrammingError):
|
except (OperationalError, ProgrammingError):
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
name: 'M2x4 LPHS'
|
name: 'M2x4 LPHS'
|
||||||
description: 'M2x4 low profile head screw'
|
description: 'M2x4 low profile head screw'
|
||||||
category: 8
|
category: 8
|
||||||
link: www.acme.com/parts/m2x4lphs
|
link: http://www.acme.com/parts/m2x4lphs
|
||||||
tree_id: 0
|
tree_id: 0
|
||||||
purchaseable: True
|
purchaseable: True
|
||||||
level: 0
|
level: 0
|
||||||
@ -56,6 +56,7 @@
|
|||||||
fields:
|
fields:
|
||||||
name: 'C_22N_0805'
|
name: 'C_22N_0805'
|
||||||
description: '22nF capacitor in 0805 package'
|
description: '22nF capacitor in 0805 package'
|
||||||
|
purchaseable: true
|
||||||
category: 3
|
category: 3
|
||||||
tree_id: 0
|
tree_id: 0
|
||||||
level: 0
|
level: 0
|
||||||
|
51
InvenTree/part/fixtures/part_pricebreaks.yaml
Normal file
51
InvenTree/part/fixtures/part_pricebreaks.yaml
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
# Sell price breaks for parts
|
||||||
|
|
||||||
|
# Price breaks for R_2K2_0805
|
||||||
|
|
||||||
|
- model: part.partsellpricebreak
|
||||||
|
pk: 1
|
||||||
|
fields:
|
||||||
|
part: 3
|
||||||
|
quantity: 1
|
||||||
|
price: 0.15
|
||||||
|
|
||||||
|
- model: part.partsellpricebreak
|
||||||
|
pk: 2
|
||||||
|
fields:
|
||||||
|
part: 3
|
||||||
|
quantity: 10
|
||||||
|
price: 0.10
|
||||||
|
|
||||||
|
|
||||||
|
# Internal price breaks for parts
|
||||||
|
|
||||||
|
# Internal Price breaks for R_2K2_0805
|
||||||
|
|
||||||
|
- model: part.partinternalpricebreak
|
||||||
|
pk: 1
|
||||||
|
fields:
|
||||||
|
part: 3
|
||||||
|
quantity: 1
|
||||||
|
price: 0.08
|
||||||
|
|
||||||
|
- model: part.partinternalpricebreak
|
||||||
|
pk: 2
|
||||||
|
fields:
|
||||||
|
part: 3
|
||||||
|
quantity: 10
|
||||||
|
price: 0.05
|
||||||
|
|
||||||
|
# Internal Price breaks for C_22N_0805
|
||||||
|
- model: part.partinternalpricebreak
|
||||||
|
pk: 3
|
||||||
|
fields:
|
||||||
|
part: 5
|
||||||
|
quantity: 1
|
||||||
|
price: 1
|
||||||
|
|
||||||
|
- model: part.partinternalpricebreak
|
||||||
|
pk: 4
|
||||||
|
fields:
|
||||||
|
part: 5
|
||||||
|
quantity: 24
|
||||||
|
price: 0.5
|
@ -20,7 +20,7 @@ from .models import BomItem
|
|||||||
from .models import PartParameterTemplate, PartParameter
|
from .models import PartParameterTemplate, PartParameter
|
||||||
from .models import PartCategoryParameterTemplate
|
from .models import PartCategoryParameterTemplate
|
||||||
from .models import PartTestTemplate
|
from .models import PartTestTemplate
|
||||||
from .models import PartSellPriceBreak
|
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||||
|
|
||||||
|
|
||||||
class PartModelChoiceField(forms.ModelChoiceField):
|
class PartModelChoiceField(forms.ModelChoiceField):
|
||||||
@ -394,3 +394,19 @@ class EditPartSalePriceBreakForm(HelperForm):
|
|||||||
'quantity',
|
'quantity',
|
||||||
'price',
|
'price',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class EditPartInternalPriceBreakForm(HelperForm):
|
||||||
|
"""
|
||||||
|
Form for creating / editing a internal price for a part
|
||||||
|
"""
|
||||||
|
|
||||||
|
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = PartInternalPriceBreak
|
||||||
|
fields = [
|
||||||
|
'part',
|
||||||
|
'quantity',
|
||||||
|
'price',
|
||||||
|
]
|
||||||
|
@ -71,6 +71,7 @@ def migrate_currencies(apps, schema_editor):
|
|||||||
|
|
||||||
count += 1
|
count += 1
|
||||||
|
|
||||||
|
if count > 0:
|
||||||
print(f"Updated {count} SupplierPriceBreak rows")
|
print(f"Updated {count} SupplierPriceBreak rows")
|
||||||
|
|
||||||
def reverse_currencies(apps, schema_editor):
|
def reverse_currencies(apps, schema_editor):
|
||||||
|
30
InvenTree/part/migrations/0067_partinternalpricebreak.py
Normal file
30
InvenTree/part/migrations/0067_partinternalpricebreak.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 3.2 on 2021-06-05 14:13
|
||||||
|
|
||||||
|
import InvenTree.fields
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import djmoney.models.fields
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('part', '0066_bomitem_allow_variants'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PartInternalPriceBreak',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('quantity', InvenTree.fields.RoundingDecimalField(decimal_places=5, default=1, help_text='Price break quantity', max_digits=15, validators=[django.core.validators.MinValueValidator(1)], verbose_name='Quantity')),
|
||||||
|
('price_currency', djmoney.models.fields.CurrencyField(choices=[('AUD', 'Australian Dollar'), ('CAD', 'Canadian Dollar'), ('EUR', 'Euro'), ('NZD', 'New Zealand Dollar'), ('GBP', 'Pound Sterling'), ('USD', 'US Dollar'), ('JPY', 'Yen')], default='USD', editable=False, max_length=3)),
|
||||||
|
('price', djmoney.models.fields.MoneyField(decimal_places=4, default_currency='USD', help_text='Unit price at specified quantity', max_digits=19, null=True, verbose_name='Price')),
|
||||||
|
('part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='internalpricebreaks', to='part.part', verbose_name='Part')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'unique_together': {('part', 'quantity')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
17
InvenTree/part/migrations/0068_part_unique_part.py
Normal file
17
InvenTree/part/migrations/0068_part_unique_part.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 3.2.4 on 2021-06-21 23:10
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('part', '0067_partinternalpricebreak'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='part',
|
||||||
|
constraint=models.UniqueConstraint(fields=('name', 'IPN', 'revision'), name='unique_part'),
|
||||||
|
),
|
||||||
|
]
|
35
InvenTree/part/migrations/0069_auto_20210701_0509.py
Normal file
35
InvenTree/part/migrations/0069_auto_20210701_0509.py
Normal file
@ -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 = [
|
||||||
|
('part', '0068_part_unique_part'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='partinternalpricebreak',
|
||||||
|
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='partinternalpricebreak',
|
||||||
|
name='price_currency',
|
||||||
|
field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='partsellpricebreak',
|
||||||
|
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='partsellpricebreak',
|
||||||
|
name='price_currency',
|
||||||
|
field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3),
|
||||||
|
),
|
||||||
|
]
|
@ -39,7 +39,7 @@ from InvenTree import helpers
|
|||||||
from InvenTree import validators
|
from InvenTree import validators
|
||||||
from InvenTree.models import InvenTreeTree, InvenTreeAttachment
|
from InvenTree.models import InvenTreeTree, InvenTreeAttachment
|
||||||
from InvenTree.fields import InvenTreeURLField
|
from InvenTree.fields import InvenTreeURLField
|
||||||
from InvenTree.helpers import decimal2string, normalize
|
from InvenTree.helpers import decimal2string, normalize, decimal2money
|
||||||
|
|
||||||
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus
|
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus
|
||||||
|
|
||||||
@ -321,6 +321,9 @@ class Part(MPTTModel):
|
|||||||
verbose_name = _("Part")
|
verbose_name = _("Part")
|
||||||
verbose_name_plural = _("Parts")
|
verbose_name_plural = _("Parts")
|
||||||
ordering = ['name', ]
|
ordering = ['name', ]
|
||||||
|
constraints = [
|
||||||
|
UniqueConstraint(fields=['name', 'IPN', 'revision'], name='unique_part')
|
||||||
|
]
|
||||||
|
|
||||||
class MPTTMeta:
|
class MPTTMeta:
|
||||||
# For legacy reasons the 'variant_of' field is used to indicate the MPTT parent
|
# For legacy reasons the 'variant_of' field is used to indicate the MPTT parent
|
||||||
@ -379,8 +382,7 @@ class Part(MPTTModel):
|
|||||||
logger.info(f"Deleting unused image file '{previous.image}'")
|
logger.info(f"Deleting unused image file '{previous.image}'")
|
||||||
previous.image.delete(save=False)
|
previous.image.delete(save=False)
|
||||||
|
|
||||||
self.clean()
|
self.full_clean()
|
||||||
self.validate_unique()
|
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
@ -643,23 +645,6 @@ class Part(MPTTModel):
|
|||||||
'IPN': _('Duplicate IPN not allowed in part settings'),
|
'IPN': _('Duplicate IPN not allowed in part settings'),
|
||||||
})
|
})
|
||||||
|
|
||||||
# Part name uniqueness should be case insensitive
|
|
||||||
try:
|
|
||||||
parts = Part.objects.exclude(id=self.id).filter(
|
|
||||||
name__iexact=self.name,
|
|
||||||
IPN__iexact=self.IPN,
|
|
||||||
revision__iexact=self.revision)
|
|
||||||
|
|
||||||
if parts.exists():
|
|
||||||
msg = _("Part must be unique for name, IPN and revision")
|
|
||||||
raise ValidationError({
|
|
||||||
"name": msg,
|
|
||||||
"IPN": msg,
|
|
||||||
"revision": msg,
|
|
||||||
})
|
|
||||||
except Part.DoesNotExist:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
"""
|
"""
|
||||||
Perform cleaning operations for the Part model
|
Perform cleaning operations for the Part model
|
||||||
@ -1494,16 +1479,17 @@ class Part(MPTTModel):
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def get_price_info(self, quantity=1, buy=True, bom=True):
|
def get_price_info(self, quantity=1, buy=True, bom=True, internal=False):
|
||||||
""" Return a simplified pricing string for this part
|
""" Return a simplified pricing string for this part
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
quantity: Number of units to calculate price for
|
quantity: Number of units to calculate price for
|
||||||
buy: Include supplier pricing (default = True)
|
buy: Include supplier pricing (default = True)
|
||||||
bom: Include BOM pricing (default = True)
|
bom: Include BOM pricing (default = True)
|
||||||
|
internal: Include internal pricing (default = False)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
price_range = self.get_price_range(quantity, buy, bom)
|
price_range = self.get_price_range(quantity, buy, bom, internal)
|
||||||
|
|
||||||
if price_range is None:
|
if price_range is None:
|
||||||
return None
|
return None
|
||||||
@ -1544,7 +1530,7 @@ class Part(MPTTModel):
|
|||||||
|
|
||||||
return (min_price, max_price)
|
return (min_price, max_price)
|
||||||
|
|
||||||
def get_bom_price_range(self, quantity=1):
|
def get_bom_price_range(self, quantity=1, internal=False):
|
||||||
""" Return the price range of the BOM for this part.
|
""" Return the price range of the BOM for this part.
|
||||||
Adds the minimum price for all components in the BOM.
|
Adds the minimum price for all components in the BOM.
|
||||||
|
|
||||||
@ -1561,7 +1547,7 @@ class Part(MPTTModel):
|
|||||||
print("Warning: Item contains itself in BOM")
|
print("Warning: Item contains itself in BOM")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
prices = item.sub_part.get_price_range(quantity * item.quantity)
|
prices = item.sub_part.get_price_range(quantity * item.quantity, internal=internal)
|
||||||
|
|
||||||
if prices is None:
|
if prices is None:
|
||||||
continue
|
continue
|
||||||
@ -1585,19 +1571,25 @@ class Part(MPTTModel):
|
|||||||
|
|
||||||
return (min_price, max_price)
|
return (min_price, max_price)
|
||||||
|
|
||||||
def get_price_range(self, quantity=1, buy=True, bom=True):
|
def get_price_range(self, quantity=1, buy=True, bom=True, internal=False):
|
||||||
|
|
||||||
""" Return the price range for this part. This price can be either:
|
""" Return the price range for this part. This price can be either:
|
||||||
|
|
||||||
- Supplier price (if purchased from suppliers)
|
- Supplier price (if purchased from suppliers)
|
||||||
- BOM price (if built from other parts)
|
- BOM price (if built from other parts)
|
||||||
|
- Internal price (if set for the part)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Minimum of the supplier price or BOM price. If no pricing available, returns None
|
Minimum of the supplier, BOM or internal price. If no pricing available, returns None
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# only get internal price if set and should be used
|
||||||
|
if internal and self.has_internal_price_breaks:
|
||||||
|
internal_price = self.get_internal_price(quantity)
|
||||||
|
return internal_price, internal_price
|
||||||
|
|
||||||
buy_price_range = self.get_supplier_price_range(quantity) if buy else None
|
buy_price_range = self.get_supplier_price_range(quantity) if buy else None
|
||||||
bom_price_range = self.get_bom_price_range(quantity) if bom else None
|
bom_price_range = self.get_bom_price_range(quantity, internal=internal) if bom else None
|
||||||
|
|
||||||
if buy_price_range is None:
|
if buy_price_range is None:
|
||||||
return bom_price_range
|
return bom_price_range
|
||||||
@ -1649,6 +1641,22 @@ class Part(MPTTModel):
|
|||||||
price=price
|
price=price
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_internal_price(self, quantity, moq=True, multiples=True, currency=None):
|
||||||
|
return common.models.get_price(self, quantity, moq, multiples, currency, break_name='internal_price_breaks')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_internal_price_breaks(self):
|
||||||
|
return self.internal_price_breaks.count() > 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def internal_price_breaks(self):
|
||||||
|
""" Return the associated price breaks in the correct order """
|
||||||
|
return self.internalpricebreaks.order_by('quantity').all()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def internal_unit_pricing(self):
|
||||||
|
return self.get_internal_price(1)
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def copy_bom_from(self, other, clear=True, **kwargs):
|
def copy_bom_from(self, other, clear=True, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -1983,6 +1991,21 @@ class PartSellPriceBreak(common.models.PriceBreak):
|
|||||||
unique_together = ('part', 'quantity')
|
unique_together = ('part', 'quantity')
|
||||||
|
|
||||||
|
|
||||||
|
class PartInternalPriceBreak(common.models.PriceBreak):
|
||||||
|
"""
|
||||||
|
Represents a price break for internally selling this part
|
||||||
|
"""
|
||||||
|
|
||||||
|
part = models.ForeignKey(
|
||||||
|
Part, on_delete=models.CASCADE,
|
||||||
|
related_name='internalpricebreaks',
|
||||||
|
verbose_name=_('Part')
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('part', 'quantity')
|
||||||
|
|
||||||
|
|
||||||
class PartStar(models.Model):
|
class PartStar(models.Model):
|
||||||
""" A PartStar object creates a relationship between a User and a Part.
|
""" A PartStar object creates a relationship between a User and a Part.
|
||||||
|
|
||||||
@ -2391,7 +2414,7 @@ class BomItem(models.Model):
|
|||||||
return "{n} x {child} to make {parent}".format(
|
return "{n} x {child} to make {parent}".format(
|
||||||
parent=self.part.full_name,
|
parent=self.part.full_name,
|
||||||
child=self.sub_part.full_name,
|
child=self.sub_part.full_name,
|
||||||
n=helpers.decimal2string(self.quantity))
|
n=decimal2string(self.quantity))
|
||||||
|
|
||||||
def available_stock(self):
|
def available_stock(self):
|
||||||
"""
|
"""
|
||||||
@ -2475,10 +2498,12 @@ class BomItem(models.Model):
|
|||||||
return required
|
return required
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def price_range(self):
|
def price_range(self, internal=False):
|
||||||
""" Return the price-range for this BOM item. """
|
""" Return the price-range for this BOM item. """
|
||||||
|
|
||||||
prange = self.sub_part.get_price_range(self.quantity)
|
# get internal price setting
|
||||||
|
use_internal = common.models.InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False)
|
||||||
|
prange = self.sub_part.get_price_range(self.quantity, internal=use_internal and internal)
|
||||||
|
|
||||||
if prange is None:
|
if prange is None:
|
||||||
return prange
|
return prange
|
||||||
@ -2486,11 +2511,11 @@ class BomItem(models.Model):
|
|||||||
pmin, pmax = prange
|
pmin, pmax = prange
|
||||||
|
|
||||||
if pmin == pmax:
|
if pmin == pmax:
|
||||||
return decimal2string(pmin)
|
return decimal2money(pmin)
|
||||||
|
|
||||||
# Convert to better string representation
|
# Convert to better string representation
|
||||||
pmin = decimal2string(pmin)
|
pmin = decimal2money(pmin)
|
||||||
pmax = decimal2string(pmax)
|
pmax = decimal2money(pmax)
|
||||||
|
|
||||||
return "{pmin} to {pmax}".format(pmin=pmin, pmax=pmax)
|
return "{pmin} to {pmax}".format(pmin=pmin, pmax=pmax)
|
||||||
|
|
||||||
|
@ -7,17 +7,21 @@ from decimal import Decimal
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
|
|
||||||
InvenTreeModelSerializer)
|
|
||||||
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from sql_util.utils import SubqueryCount, SubquerySum
|
from sql_util.utils import SubqueryCount, SubquerySum
|
||||||
from djmoney.contrib.django_rest_framework import MoneyField
|
from djmoney.contrib.django_rest_framework import MoneyField
|
||||||
|
|
||||||
|
from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
|
||||||
|
InvenTreeImageSerializerField,
|
||||||
|
InvenTreeModelSerializer)
|
||||||
|
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
|
||||||
from stock.models import StockItem
|
from stock.models import StockItem
|
||||||
|
|
||||||
from .models import (BomItem, Part, PartAttachment, PartCategory,
|
from .models import (BomItem, Part, PartAttachment, PartCategory,
|
||||||
PartParameter, PartParameterTemplate, PartSellPriceBreak,
|
PartParameter, PartParameterTemplate, PartSellPriceBreak,
|
||||||
PartStar, PartTestTemplate, PartCategoryParameterTemplate)
|
PartStar, PartTestTemplate, PartCategoryParameterTemplate,
|
||||||
|
PartInternalPriceBreak)
|
||||||
|
|
||||||
|
|
||||||
class CategorySerializer(InvenTreeModelSerializer):
|
class CategorySerializer(InvenTreeModelSerializer):
|
||||||
@ -100,6 +104,25 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class PartInternalPriceSerializer(InvenTreeModelSerializer):
|
||||||
|
"""
|
||||||
|
Serializer for internal prices for Part model.
|
||||||
|
"""
|
||||||
|
|
||||||
|
quantity = serializers.FloatField()
|
||||||
|
|
||||||
|
price = serializers.CharField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = PartInternalPriceBreak
|
||||||
|
fields = [
|
||||||
|
'pk',
|
||||||
|
'part',
|
||||||
|
'quantity',
|
||||||
|
'price',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class PartThumbSerializer(serializers.Serializer):
|
class PartThumbSerializer(serializers.Serializer):
|
||||||
"""
|
"""
|
||||||
Serializer for the 'image' field of the Part model.
|
Serializer for the 'image' field of the Part model.
|
||||||
@ -280,7 +303,7 @@ class PartSerializer(InvenTreeModelSerializer):
|
|||||||
stock_item_count = serializers.IntegerField(read_only=True)
|
stock_item_count = serializers.IntegerField(read_only=True)
|
||||||
suppliers = serializers.IntegerField(read_only=True)
|
suppliers = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
image = serializers.CharField(source='get_image_url', read_only=True)
|
image = InvenTreeImageSerializerField(required=False, allow_null=True)
|
||||||
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
|
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
|
||||||
starred = serializers.SerializerMethodField()
|
starred = serializers.SerializerMethodField()
|
||||||
|
|
||||||
@ -354,7 +377,7 @@ class PartStarSerializer(InvenTreeModelSerializer):
|
|||||||
class BomItemSerializer(InvenTreeModelSerializer):
|
class BomItemSerializer(InvenTreeModelSerializer):
|
||||||
""" Serializer for BomItem object """
|
""" Serializer for BomItem object """
|
||||||
|
|
||||||
# price_range = serializers.CharField(read_only=True)
|
price_range = serializers.CharField(read_only=True)
|
||||||
|
|
||||||
quantity = serializers.FloatField()
|
quantity = serializers.FloatField()
|
||||||
|
|
||||||
@ -469,7 +492,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
|||||||
'reference',
|
'reference',
|
||||||
'sub_part',
|
'sub_part',
|
||||||
'sub_part_detail',
|
'sub_part_detail',
|
||||||
# 'price_range',
|
'price_range',
|
||||||
'validated',
|
'validated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -8,52 +8,43 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block heading %}
|
{% block heading %}
|
||||||
{% trans "Part Stock Allocations" %}
|
{% trans "Build Order Allocations" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block details %}
|
{% block details %}
|
||||||
<table class='table table-striped table-condensed' id='build-table'>
|
|
||||||
<tr>
|
<table class='table table-striped table-condensed' id='build-order-table'></table>
|
||||||
<th>{% trans "Order" %}</th>
|
|
||||||
<th>{% trans "Stock Item" %}</th>
|
|
||||||
<th>{% trans "Quantity" %}</th>
|
|
||||||
</tr>
|
|
||||||
{% for allocation in part.build_order_allocations %}
|
|
||||||
<tr>
|
|
||||||
<td><a href="{% url 'build-detail' allocation.build.id %}">{% trans "Build Order" %}: {{ allocation.build }}</a></td>
|
|
||||||
<td><a href="{% url 'stock-item-detail' allocation.stock_item.id %}">{% trans "Stock Item" %}: {{ allocation.stock_item }}</a></td>
|
|
||||||
<td>{% decimal allocation.quantity %}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
{% for allocation in part.sales_order_allocations %}
|
|
||||||
<tr>
|
|
||||||
<td><a href="{% url 'so-detail' allocation.line.order.id %}">{% trans "Sales Order" %}: {{ allocation.line.order }}</a></td>
|
|
||||||
<td><a href="{% url 'stock-item-detail' allocation.item.id %}">{% trans "Stock Item" %}: {{ allocation.item }}</a></td>
|
|
||||||
<td>{% decimal allocation.quantity %}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</table>
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block pre_content_panel %}
|
||||||
|
|
||||||
|
<div class='panel panel-default panel-inventree'>
|
||||||
|
<div class='panel-heading'>
|
||||||
|
<h4>{% trans "Sales Order Allocations" %}</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class='panel-content'>
|
||||||
|
<table class='table table-striped table-condensed' id='sales-order-table'></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
{% block js_ready %}
|
{% block js_ready %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
|
||||||
$("#build-table").inventreeTable({
|
loadSalesOrderAllocationTable("#sales-order-table", {
|
||||||
columns: [
|
params: {
|
||||||
{
|
part: {{ part.id }},
|
||||||
title: '{% trans "Order" %}',
|
}
|
||||||
sortable: true,
|
});
|
||||||
},
|
|
||||||
{
|
loadBuildOrderAllocationTable("#build-order-table", {
|
||||||
title: '{% trans "Stock Item" %}',
|
params: {
|
||||||
sortable: true,
|
part: {{ part.id }},
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '{% trans "Quantity" %}',
|
|
||||||
sortable: true,
|
|
||||||
}
|
}
|
||||||
]
|
|
||||||
});
|
});
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -8,6 +8,15 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class='{{ message.tags }}'>
|
||||||
|
{{ message|safe }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
<div class='panel panel-default panel-inventree'>
|
<div class='panel panel-default panel-inventree'>
|
||||||
<div class='row'>
|
<div class='row'>
|
||||||
<div class='col-sm-6'>
|
<div class='col-sm-6'>
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load inventree_extras %}
|
||||||
|
|
||||||
|
{% settings_value 'PART_SHOW_IMPORT' as show_import %}
|
||||||
|
|
||||||
<ul class='list-group'>
|
<ul class='list-group'>
|
||||||
|
|
||||||
@ -30,6 +33,15 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
{% if show_import and user.is_staff and roles.part.add %}
|
||||||
|
<li class='list-group-item {% if tab == "import" %}active{% endif %}' title='{% trans "Import Parts" %}'>
|
||||||
|
<a href='{% url "part-import" %}'>
|
||||||
|
<span class='fas fa-file-upload sidebar-icon'></span>
|
||||||
|
{% trans "Import Parts" %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if category %}
|
{% if category %}
|
||||||
<li class='list-group-item {% if tab == "parameters" %}active{% endif %}' title='{% trans "Parameters" %}'>
|
<li class='list-group-item {% if tab == "parameters" %}active{% endif %}' title='{% trans "Parameters" %}'>
|
||||||
<a href='{% url "category-parametric" category.id %}'>
|
<a href='{% url "category-parametric" category.id %}'>
|
||||||
|
@ -0,0 +1,89 @@
|
|||||||
|
{% extends "part/import_wizard/ajax_part_upload.html" %}
|
||||||
|
{% load inventree_extras %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block form_alert %}
|
||||||
|
{% if missing_columns and missing_columns|length > 0 %}
|
||||||
|
<div class='alert alert-danger alert-block' role='alert'>
|
||||||
|
{% trans "Missing selections for the following required columns" %}:
|
||||||
|
<br>
|
||||||
|
<ul>
|
||||||
|
{% for col in missing_columns %}
|
||||||
|
<li>{{ col }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if duplicates and duplicates|length > 0 %}
|
||||||
|
<div class='alert alert-danger alert-block' role='alert'>
|
||||||
|
{% trans "Duplicate selections found, see below. Fix them then retry submitting." %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock form_alert %}
|
||||||
|
|
||||||
|
{% block form_content %}
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "File Fields" %}</th>
|
||||||
|
<th></th>
|
||||||
|
{% for col in form %}
|
||||||
|
<th>
|
||||||
|
<div>
|
||||||
|
<input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/>
|
||||||
|
{{ col.name }}
|
||||||
|
<button class='btn btn-default btn-remove' onClick='removeColFromBomWizard()' id='del_col_{{ forloop.counter0 }}' style='display: inline; float: right;' title='{% trans "Remove column" %}'>
|
||||||
|
<span col_id='{{ forloop.counter0 }}' class='fas fa-trash-alt icon-red'></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>{% trans "Match Fields" %}</td>
|
||||||
|
<td></td>
|
||||||
|
{% for col in form %}
|
||||||
|
<td>
|
||||||
|
{{ col }}
|
||||||
|
{% for duplicate in duplicates %}
|
||||||
|
{% if duplicate == col.value %}
|
||||||
|
<div class='alert alert-danger alert-block text-center' role='alert' style='padding:2px; margin-top:6px; margin-bottom:2px'>
|
||||||
|
<b>{% trans "Duplicate selection" %}</b>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% for row in rows %}
|
||||||
|
{% with forloop.counter as row_index %}
|
||||||
|
<tr>
|
||||||
|
<td style='width: 32px;'>
|
||||||
|
<button class='btn btn-default btn-remove' onClick='removeRowFromBomWizard()' id='del_row_{{ row_index }}' style='display: inline; float: left;' title='{% trans "Remove row" %}'>
|
||||||
|
<span row_id='{{ row_index }}' class='fas fa-trash-alt icon-red'></span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td style='text-align: left;'>{{ row_index }}</td>
|
||||||
|
{% for item in row.data %}
|
||||||
|
<td>
|
||||||
|
<input type='hidden' name='row_{{ row_index }}_col_{{ forloop.counter0 }}' value='{{ item }}'/>
|
||||||
|
{{ item }}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
{% endblock form_content %}
|
||||||
|
|
||||||
|
{% block js_ready %}
|
||||||
|
{{ block.super }}
|
||||||
|
|
||||||
|
$('.fieldselect').select2({
|
||||||
|
width: '100%',
|
||||||
|
matcher: partialMatcher,
|
||||||
|
});
|
||||||
|
|
||||||
|
{% endblock %}
|
@ -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 %}
|
||||||
|
<div class='alert alert-danger alert-block' role='alert'>
|
||||||
|
{% trans "Errors exist in the submitted data" %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock form_alert %}
|
||||||
|
|
||||||
|
{% block form_content %}
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th>{% trans "Row" %}</th>
|
||||||
|
{% for col in columns %}
|
||||||
|
|
||||||
|
<th>
|
||||||
|
<input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/>
|
||||||
|
<input type='hidden' name='col_guess_{{ forloop.counter0 }}' value='{{ col.guess }}'/>
|
||||||
|
{% if col.guess %}
|
||||||
|
{{ col.guess }}
|
||||||
|
{% else %}
|
||||||
|
{{ col.name }}
|
||||||
|
{% endif %}
|
||||||
|
</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr></tr> {% comment %} Dummy row for javascript del_row method {% endcomment %}
|
||||||
|
{% for row in rows %}
|
||||||
|
<tr {% if row.errors %} style='background: #ffeaea;'{% endif %} part-select='#select_part_{{ row.index }}'>
|
||||||
|
<td>
|
||||||
|
<button class='btn btn-default btn-remove' onClick='removeRowFromBomWizard()' id='del_row_{{ row.index }}' style='display: inline; float: right;' title='{% trans "Remove row" %}'>
|
||||||
|
<span row_id='{{ row.index }}' class='fas fa-trash-alt icon-red'></span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% add row.index 1 %}
|
||||||
|
</td>
|
||||||
|
{% for item in row.data %}
|
||||||
|
<td>
|
||||||
|
{% 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 %}
|
||||||
|
<input type='hidden' name='row_{{ row.index }}_col_{{ forloop.counter0 }}' value='{{ item.cell }}'/>
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
{% 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 %}
|
@ -0,0 +1,33 @@
|
|||||||
|
{% extends "modal_form.html" %}
|
||||||
|
|
||||||
|
{% load inventree_extras %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block form %}
|
||||||
|
|
||||||
|
{% if roles.part.change %}
|
||||||
|
|
||||||
|
<p>{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %}
|
||||||
|
{% if description %}- {{ description }}{% endif %}</p>
|
||||||
|
|
||||||
|
{% block form_alert %}
|
||||||
|
{% endblock form_alert %}
|
||||||
|
|
||||||
|
<form action="" method="post" class='js-modal-form' enctype="multipart/form-data">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
<table class='table table-striped' style='margin-top: 12px; margin-bottom: 0px'>
|
||||||
|
{{ wizard.management_form }}
|
||||||
|
{% block form_content %}
|
||||||
|
{% crispy wizard.form %}
|
||||||
|
{% endblock form_content %}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class='alert alert-danger alert-block' role='alert'>
|
||||||
|
{% trans "Unsuffitient privileges." %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
@ -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 %}
|
||||||
|
<div class='alert alert-danger alert-block' role='alert'>
|
||||||
|
{% trans "Missing selections for the following required columns" %}:
|
||||||
|
<br>
|
||||||
|
<ul>
|
||||||
|
{% for col in missing_columns %}
|
||||||
|
<li>{{ col }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if duplicates and duplicates|length > 0 %}
|
||||||
|
<div class='alert alert-danger alert-block' role='alert'>
|
||||||
|
{% trans "Duplicate selections found, see below. Fix them then retry submitting." %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock form_alert %}
|
||||||
|
|
||||||
|
{% block form_buttons_top %}
|
||||||
|
{% if wizard.steps.prev %}
|
||||||
|
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-default">{% trans "Previous Step" %}</button>
|
||||||
|
{% endif %}
|
||||||
|
<button type="submit" class="save btn btn-default">{% trans "Submit Selections" %}</button>
|
||||||
|
{% endblock form_buttons_top %}
|
||||||
|
|
||||||
|
{% block form_content %}
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "File Fields" %}</th>
|
||||||
|
<th></th>
|
||||||
|
{% for col in form %}
|
||||||
|
<th>
|
||||||
|
<div>
|
||||||
|
<input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/>
|
||||||
|
{{ col.name }}
|
||||||
|
<button class='btn btn-default btn-remove' onClick='removeColFromBomWizard()' id='del_col_{{ forloop.counter0 }}' style='display: inline; float: right;' title='{% trans "Remove column" %}'>
|
||||||
|
<span col_id='{{ forloop.counter0 }}' class='fas fa-trash-alt icon-red'></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>{% trans "Match Fields" %}</td>
|
||||||
|
<td></td>
|
||||||
|
{% for col in form %}
|
||||||
|
<td>
|
||||||
|
{{ col }}
|
||||||
|
{% for duplicate in duplicates %}
|
||||||
|
{% if duplicate == col.value %}
|
||||||
|
<div class='alert alert-danger alert-block text-center' role='alert' style='padding:2px; margin-top:6px; margin-bottom:2px'>
|
||||||
|
<b>{% trans "Duplicate selection" %}</b>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% for row in rows %}
|
||||||
|
{% with forloop.counter as row_index %}
|
||||||
|
<tr>
|
||||||
|
<td style='width: 32px;'>
|
||||||
|
<button class='btn btn-default btn-remove' onClick='removeRowFromBomWizard()' id='del_row_{{ row_index }}' style='display: inline; float: left;' title='{% trans "Remove row" %}'>
|
||||||
|
<span row_id='{{ row_index }}' class='fas fa-trash-alt icon-red'></span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td style='text-align: left;'>{{ row_index }}</td>
|
||||||
|
{% for item in row.data %}
|
||||||
|
<td>
|
||||||
|
<input type='hidden' name='row_{{ row_index }}_col_{{ forloop.counter0 }}' value='{{ item }}'/>
|
||||||
|
{{ item }}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
{% endblock form_content %}
|
||||||
|
|
||||||
|
{% block form_buttons_bottom %}
|
||||||
|
{% endblock form_buttons_bottom %}
|
||||||
|
|
||||||
|
{% block js_ready %}
|
||||||
|
{{ block.super }}
|
||||||
|
|
||||||
|
$('.fieldselect').select2({
|
||||||
|
width: '100%',
|
||||||
|
matcher: partialMatcher,
|
||||||
|
});
|
||||||
|
|
||||||
|
{% endblock %}
|
@ -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 %}
|
||||||
|
<div class='alert alert-danger alert-block' role='alert'>
|
||||||
|
{% trans "Errors exist in the submitted data" %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock form_alert %}
|
||||||
|
|
||||||
|
{% block form_buttons_top %}
|
||||||
|
{% if wizard.steps.prev %}
|
||||||
|
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-default">{% trans "Previous Step" %}</button>
|
||||||
|
{% endif %}
|
||||||
|
<button type="submit" class="save btn btn-default">{% trans "Submit Selections" %}</button>
|
||||||
|
{% endblock form_buttons_top %}
|
||||||
|
|
||||||
|
{% block form_content %}
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th>{% trans "Row" %}</th>
|
||||||
|
{% for col in columns %}
|
||||||
|
|
||||||
|
<th>
|
||||||
|
<input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/>
|
||||||
|
<input type='hidden' name='col_guess_{{ forloop.counter0 }}' value='{{ col.guess }}'/>
|
||||||
|
{% if col.guess %}
|
||||||
|
{{ col.guess }}
|
||||||
|
{% else %}
|
||||||
|
{{ col.name }}
|
||||||
|
{% endif %}
|
||||||
|
</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr></tr> {% comment %} Dummy row for javascript del_row method {% endcomment %}
|
||||||
|
{% for row in rows %}
|
||||||
|
<tr {% if row.errors %} style='background: #ffeaea;'{% endif %} part-select='#select_part_{{ row.index }}'>
|
||||||
|
<td>
|
||||||
|
<button class='btn btn-default btn-remove' onClick='removeRowFromBomWizard()' id='del_row_{{ row.index }}' style='display: inline; float: right;' title='{% trans "Remove row" %}'>
|
||||||
|
<span row_id='{{ row.index }}' class='fas fa-trash-alt icon-red'></span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% add row.index 1 %}
|
||||||
|
</td>
|
||||||
|
{% for item in row.data %}
|
||||||
|
<td>
|
||||||
|
{% 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 %}
|
||||||
|
<input type='hidden' name='row_{{ row.index }}_col_{{ forloop.counter0 }}' value='{{ item.cell }}'/>
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
{% 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 %}
|
61
InvenTree/part/templates/part/import_wizard/part_upload.html
Normal file
61
InvenTree/part/templates/part/import_wizard/part_upload.html
Normal file
@ -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 %}
|
||||||
|
<div class='panel panel-default panel-inventree'>
|
||||||
|
<div class='panel-heading'>
|
||||||
|
<h4>
|
||||||
|
{% trans "Import Parts from File" %}
|
||||||
|
{{ wizard.form.media }}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class='panel-content'>
|
||||||
|
{% if roles.part.change %}
|
||||||
|
|
||||||
|
<p>{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %}
|
||||||
|
{% if description %}- {{ description }}{% endif %}</p>
|
||||||
|
|
||||||
|
{% block form_alert %}
|
||||||
|
{% endblock form_alert %}
|
||||||
|
|
||||||
|
<form action="" method="post" class='js-modal-form' enctype="multipart/form-data">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block form_buttons_top %}
|
||||||
|
{% endblock form_buttons_top %}
|
||||||
|
|
||||||
|
<table class='table table-striped' style='margin-top: 12px; margin-bottom: 0px'>
|
||||||
|
{{ wizard.management_form }}
|
||||||
|
{% block form_content %}
|
||||||
|
{% crispy wizard.form %}
|
||||||
|
{% endblock form_content %}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% block form_buttons_bottom %}
|
||||||
|
{% if wizard.steps.prev %}
|
||||||
|
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-default">{% trans "Previous Step" %}</button>
|
||||||
|
{% endif %}
|
||||||
|
<button type="submit" class="save btn btn-default">{% trans "Upload File" %}</button>
|
||||||
|
</form>
|
||||||
|
{% endblock form_buttons_bottom %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class='alert alert-danger alert-block' role='alert'>
|
||||||
|
{% trans "Unsuffitient privileges." %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block js_ready %}
|
||||||
|
{{ block.super }}
|
||||||
|
|
||||||
|
{% endblock %}
|
122
InvenTree/part/templates/part/internal_prices.html
Normal file
122
InvenTree/part/templates/part/internal_prices.html
Normal file
@ -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 %}
|
||||||
|
<div id='internal-price-break-toolbar' class='btn-group'>
|
||||||
|
<button class='btn btn-primary' id='new-internal-price-break' type='button'>
|
||||||
|
<span class='fas fa-plus-circle'></span> {% trans "Add Internal Price Break" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class='table table-striped table-condensed' id='internal-price-break-table' data-toolbar='#internal-price-break-toolbar'>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class='container-fluid'>
|
||||||
|
<h3>{% trans "Permission Denied" %}</h3>
|
||||||
|
|
||||||
|
<div class='alert alert-danger alert-block'>
|
||||||
|
{% trans "You do not have permission to view this page." %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% 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 += `<div class='btn-group float-right' role='group'>`
|
||||||
|
|
||||||
|
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 += `</div>`;
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
@ -2,6 +2,9 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
{% load inventree_extras %}
|
{% load inventree_extras %}
|
||||||
|
|
||||||
|
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
||||||
|
{% settings_value 'PART_SHOW_RELATED' as show_related %}
|
||||||
|
|
||||||
<ul class='list-group'>
|
<ul class='list-group'>
|
||||||
<li class='list-group-item'>
|
<li class='list-group-item'>
|
||||||
<a href='#' id='part-menu-toggle'>
|
<a href='#' id='part-menu-toggle'>
|
||||||
@ -94,7 +97,13 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if part.salable and roles.sales_order.view %}
|
{% if show_internal_price and roles.sales_order.view %}
|
||||||
|
<li class='list-group-item {% if tab == "internal-prices" %}active{% endif %}' title='{% trans "Internal Price Information" %}'>
|
||||||
|
<a href='{% url "part-internal-prices" part.id %}'>
|
||||||
|
<span class='menu-tab-icon fas fa-dollar-sign' style='width: 20px;'></span>
|
||||||
|
{% trans "Internal Price" %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li class='list-group-item {% if tab == "sales-prices" %}active{% endif %}' title='{% trans "Sales Price Information" %}'>
|
<li class='list-group-item {% if tab == "sales-prices" %}active{% endif %}' title='{% trans "Sales Price Information" %}'>
|
||||||
<a href='{% url "part-sale-prices" part.id %}'>
|
<a href='{% url "part-sale-prices" part.id %}'>
|
||||||
<span class='menu-tab-icon fas fa-dollar-sign sidebar-icon'></span>
|
<span class='menu-tab-icon fas fa-dollar-sign sidebar-icon'></span>
|
||||||
@ -116,12 +125,14 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if show_related %}
|
||||||
<li class='list-group-item {% if tab == "related" %}active{% endif %}' title='{% trans "Related Parts" %}'>
|
<li class='list-group-item {% if tab == "related" %}active{% endif %}' title='{% trans "Related Parts" %}'>
|
||||||
<a href='{% url "part-related" part.id %}'>
|
<a href='{% url "part-related" part.id %}'>
|
||||||
<span class='menu-tab-icon fas fa-random sidebar-icon'></span>
|
<span class='menu-tab-icon fas fa-random sidebar-icon'></span>
|
||||||
{% trans "Related Parts" %}
|
{% trans "Related Parts" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
{% endif %}
|
||||||
<li class='list-group-item {% if tab == "attachments" %}active{% endif %}' title='{% trans "Attachments" %}'>
|
<li class='list-group-item {% if tab == "attachments" %}active{% endif %}' title='{% trans "Attachments" %}'>
|
||||||
<a href='{% url "part-attachments" part.id %}'>
|
<a href='{% url "part-attachments" part.id %}'>
|
||||||
<span class='menu-tab-icon fas fa-paperclip sidebar-icon'></span>
|
<span class='menu-tab-icon fas fa-paperclip sidebar-icon'></span>
|
||||||
|
@ -14,8 +14,19 @@
|
|||||||
|
|
||||||
{% block details %}
|
{% block details %}
|
||||||
{% default_currency as currency %}
|
{% default_currency as currency %}
|
||||||
|
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
||||||
|
|
||||||
{% crispy form %}
|
|
||||||
|
<form method="post" class="form-horizontal">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm-9">{{ form|crispy }}</div>
|
||||||
|
<div class="col-sm-3">
|
||||||
|
<input type="submit" value="{% trans 'Calculate' %}" class="btn btn-primary btn-block">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<hr>
|
||||||
|
|
||||||
<div class="row"><div class="col col-md-6">
|
<div class="row"><div class="col col-md-6">
|
||||||
<h4>{% trans "Pricing ranges" %}</h4>
|
<h4>{% trans "Pricing ranges" %}</h4>
|
||||||
@ -77,6 +88,21 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if show_internal_price and roles.sales_order.view %}
|
||||||
|
{% if total_internal_part_price %}
|
||||||
|
<tr>
|
||||||
|
<td><b>{% trans 'Internal Price' %}</b></td>
|
||||||
|
<td>{% trans 'Unit Cost' %}</td>
|
||||||
|
<td colspan='2'>{% include "price.html" with price=unit_internal_part_price %}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>{% trans 'Total Cost' %}</td>
|
||||||
|
<td colspan='2'>{% include "price.html" with price=total_internal_part_price %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if total_part_price %}
|
{% if total_part_price %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>{% trans 'Sale Price' %}</b></td>
|
<td><b>{% trans 'Sale Price' %}</b></td>
|
||||||
@ -110,9 +136,9 @@
|
|||||||
|
|
||||||
{% if price_history %}
|
{% if price_history %}
|
||||||
<hr>
|
<hr>
|
||||||
<h4>{% trans 'Stock Pricing' %}<i class="fas fa-info-circle" title="Shows the prices of stock for this part
|
<h4>{% trans 'Stock Pricing' %}<i class="fas fa-info-circle" title="Shows the purchase prices of stock for this part.
|
||||||
the part single price shown is the current price for that supplier part"></i></h4>
|
The part single price is the current purchase price for that supplier part."></i></h4>
|
||||||
{% if price_history|length > 1 %}
|
{% if price_history|length > 0 %}
|
||||||
<div style="max-width: 99%; min-height: 300px">
|
<div style="max-width: 99%; min-height: 300px">
|
||||||
<canvas id="StockPriceChart"></canvas>
|
<canvas id="StockPriceChart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
@ -157,7 +183,8 @@ the part single price shown is the current price for that supplier part"></i></h
|
|||||||
{% for line in price_history %}{{ line.price_diff|stringformat:".2f" }},{% endfor %}
|
{% for line in price_history %}{{ line.price_diff|stringformat:".2f" }},{% endfor %}
|
||||||
],
|
],
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
type: 'line'
|
type: 'line',
|
||||||
|
hidden: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '{% blocktrans %}Part Single Price - {{currency}}{% endblocktrans %}',
|
label: '{% blocktrans %}Part Single Price - {{currency}}{% endblocktrans %}',
|
||||||
@ -168,7 +195,8 @@ the part single price shown is the current price for that supplier part"></i></h
|
|||||||
{% for line in price_history %}{{ line.price_part|stringformat:".2f" }},{% endfor %}
|
{% for line in price_history %}{{ line.price_part|stringformat:".2f" }},{% endfor %}
|
||||||
],
|
],
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
type: 'line'
|
type: 'line',
|
||||||
|
hidden: true,
|
||||||
},
|
},
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{
|
{
|
||||||
@ -187,18 +215,18 @@ the part single price shown is the current price for that supplier part"></i></h
|
|||||||
var bomdata = {
|
var bomdata = {
|
||||||
labels: [{% for line in bom_parts %}'{{ line.name }}',{% endfor %}],
|
labels: [{% for line in bom_parts %}'{{ line.name }}',{% endfor %}],
|
||||||
datasets: [
|
datasets: [
|
||||||
{% if bom_pie_min %}
|
{
|
||||||
|
label: 'Price',
|
||||||
|
data: [{% for line in bom_parts %}{{ line.min_price }},{% endfor %}],
|
||||||
|
backgroundColor: bom_colors,
|
||||||
|
},
|
||||||
|
{% if bom_pie_max %}
|
||||||
{
|
{
|
||||||
label: 'Max Price',
|
label: 'Max Price',
|
||||||
data: [{% for line in bom_parts %}{{ line.max_price }},{% endfor %}],
|
data: [{% for line in bom_parts %}{{ line.max_price }},{% endfor %}],
|
||||||
backgroundColor: bom_colors,
|
backgroundColor: bom_colors,
|
||||||
},
|
},
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{
|
|
||||||
label: 'Price',
|
|
||||||
data: [{% for line in bom_parts %}{% if bom_pie_min %}{{ line.min_price }}{% else %}{{ line.price }}{% endif%},{% endfor %}],
|
|
||||||
backgroundColor: bom_colors,
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
var BomChart = loadBomChart(document.getElementById('BomChart'), bomdata)
|
var BomChart = loadBomChart(document.getElementById('BomChart'), bomdata)
|
||||||
|
@ -195,8 +195,13 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% block pre_content_panel %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
<div class='panel panel-default panel-inventree'>
|
<div class='panel panel-default panel-inventree'>
|
||||||
|
|
||||||
|
|
||||||
<div class='panel-heading'>
|
<div class='panel-heading'>
|
||||||
<h4>
|
<h4>
|
||||||
{% block heading %}
|
{% block heading %}
|
||||||
@ -210,7 +215,11 @@
|
|||||||
<!-- Specific part details go here... -->
|
<!-- Specific part details go here... -->
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
{% block post_content_panel %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@ -230,13 +239,21 @@
|
|||||||
|
|
||||||
enableDragAndDrop(
|
enableDragAndDrop(
|
||||||
'#part-thumb',
|
'#part-thumb',
|
||||||
"{% url 'part-image-upload' part.id %}",
|
"{% url 'api-part-detail' part.id %}",
|
||||||
{
|
{
|
||||||
label: 'image',
|
label: 'image',
|
||||||
|
method: 'PATCH',
|
||||||
success: function(data, status, xhr) {
|
success: function(data, status, xhr) {
|
||||||
|
|
||||||
|
// If image / thumbnail data present, live update
|
||||||
|
if (data.image) {
|
||||||
|
$('#part-image').attr('src', data.image);
|
||||||
|
} else {
|
||||||
|
// Otherwise, reload the page
|
||||||
location.reload();
|
location.reload();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
$("#show-qr-code").click(function() {
|
$("#show-qr-code").click(function() {
|
||||||
|
@ -3,7 +3,10 @@
|
|||||||
{% load i18n inventree_extras %}
|
{% load i18n inventree_extras %}
|
||||||
|
|
||||||
{% block pre_form_content %}
|
{% block pre_form_content %}
|
||||||
|
|
||||||
{% default_currency as currency %}
|
{% default_currency as currency %}
|
||||||
|
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
||||||
|
|
||||||
<table class='table table-striped table-condensed table-price-two'>
|
<table class='table table-striped table-condensed table-price-two'>
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>{% trans 'Part' %}</b></td>
|
<td><b>{% trans 'Part' %}</b></td>
|
||||||
@ -74,6 +77,22 @@
|
|||||||
</table>
|
</table>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if show_internal_price and roles.sales_order.view %}
|
||||||
|
{% if total_internal_part_price %}
|
||||||
|
<h4>{% trans 'Internal Price' %}</h4>
|
||||||
|
<table class='table table-striped table-condensed table-price-two'>
|
||||||
|
<tr>
|
||||||
|
<td><b>{% trans 'Unit Cost' %}</b></td>
|
||||||
|
<td>{% include "price.html" with price=unit_internal_part_price %}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>{% trans 'Total Cost' %}</b></td>
|
||||||
|
<td>{% include "price.html" with price=total_internal_part_price %}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if total_part_price %}
|
{% if total_part_price %}
|
||||||
<h4>{% trans 'Sale Price' %}</h4>
|
<h4>{% trans 'Sale Price' %}</h4>
|
||||||
<table class='table table-striped table-condensed table-price-two'>
|
<table class='table table-striped table-condensed table-price-two'>
|
||||||
|
@ -5,6 +5,7 @@ over and above the built-in Django tags.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
from django.conf import settings as djangosettings
|
from django.conf import settings as djangosettings
|
||||||
@ -114,6 +115,14 @@ def inventree_title(*args, **kwargs):
|
|||||||
return version.inventreeInstanceTitle()
|
return version.inventreeInstanceTitle()
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag()
|
||||||
|
def python_version(*args, **kwargs):
|
||||||
|
"""
|
||||||
|
Return the current python version
|
||||||
|
"""
|
||||||
|
return sys.version.split(' ')[0]
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag()
|
@register.simple_tag()
|
||||||
def inventree_version(*args, **kwargs):
|
def inventree_version(*args, **kwargs):
|
||||||
""" Return InvenTree version string """
|
""" Return InvenTree version string """
|
||||||
@ -208,6 +217,29 @@ def get_color_theme_css(username):
|
|||||||
return inventree_css_static_url
|
return inventree_css_static_url
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def keyvalue(dict, key):
|
||||||
|
"""
|
||||||
|
access to key of supplied dict
|
||||||
|
|
||||||
|
usage:
|
||||||
|
{% mydict|keyvalue:mykey %}
|
||||||
|
"""
|
||||||
|
return dict[key]
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag()
|
||||||
|
def call_method(obj, method_name, *args):
|
||||||
|
"""
|
||||||
|
enables calling model methods / functions from templates with arguments
|
||||||
|
|
||||||
|
usage:
|
||||||
|
{% call_method model_object 'fnc_name' argument1 %}
|
||||||
|
"""
|
||||||
|
method = getattr(obj, method_name)
|
||||||
|
return method(*args)
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag()
|
@register.simple_tag()
|
||||||
def authorized_owners(group):
|
def authorized_owners(group):
|
||||||
""" Return authorized owners """
|
""" Return authorized owners """
|
||||||
|
@ -1,14 +1,20 @@
|
|||||||
from rest_framework import status
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import PIL
|
||||||
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from part.models import Part
|
from rest_framework import status
|
||||||
from stock.models import StockItem
|
from rest_framework.test import APIClient
|
||||||
from company.models import Company
|
|
||||||
|
|
||||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||||
from InvenTree.status_codes import StockStatus
|
from InvenTree.status_codes import StockStatus
|
||||||
|
|
||||||
|
from part.models import Part, PartCategory
|
||||||
|
from stock.models import StockItem
|
||||||
|
from company.models import Company
|
||||||
|
from common.models import InvenTreeSetting
|
||||||
|
|
||||||
|
|
||||||
class PartAPITest(InvenTreeAPITestCase):
|
class PartAPITest(InvenTreeAPITestCase):
|
||||||
"""
|
"""
|
||||||
@ -230,6 +236,18 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
response = self.client.get(url, data={'part': 10004})
|
response = self.client.get(url, data={'part': 10004})
|
||||||
self.assertEqual(len(response.data), 7)
|
self.assertEqual(len(response.data), 7)
|
||||||
|
|
||||||
|
# Try to post a new object (missing description)
|
||||||
|
response = self.client.post(
|
||||||
|
url,
|
||||||
|
data={
|
||||||
|
'part': 10000,
|
||||||
|
'test_name': 'My very first test',
|
||||||
|
'required': False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
# Try to post a new object (should succeed)
|
# Try to post a new object (should succeed)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
url,
|
url,
|
||||||
@ -237,6 +255,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
'part': 10000,
|
'part': 10000,
|
||||||
'test_name': 'New Test',
|
'test_name': 'New Test',
|
||||||
'required': True,
|
'required': True,
|
||||||
|
'description': 'a test description'
|
||||||
},
|
},
|
||||||
format='json',
|
format='json',
|
||||||
)
|
)
|
||||||
@ -248,7 +267,8 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
url,
|
url,
|
||||||
data={
|
data={
|
||||||
'part': 10004,
|
'part': 10004,
|
||||||
'test_name': " newtest"
|
'test_name': " newtest",
|
||||||
|
'description': 'dafsdf',
|
||||||
},
|
},
|
||||||
format='json',
|
format='json',
|
||||||
)
|
)
|
||||||
@ -292,6 +312,297 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
self.assertEqual(len(data['results']), n)
|
self.assertEqual(len(data['results']), n)
|
||||||
|
|
||||||
|
def test_default_values(self):
|
||||||
|
"""
|
||||||
|
Tests for 'default' values:
|
||||||
|
|
||||||
|
Ensure that unspecified fields revert to "default" values
|
||||||
|
(as specified in the model field definition)
|
||||||
|
"""
|
||||||
|
|
||||||
|
url = reverse('api-part-list')
|
||||||
|
|
||||||
|
response = self.client.post(url, {
|
||||||
|
'name': 'all defaults',
|
||||||
|
'description': 'my test part',
|
||||||
|
'category': 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
data = response.data
|
||||||
|
|
||||||
|
# Check that the un-specified fields have used correct default values
|
||||||
|
self.assertTrue(data['active'])
|
||||||
|
self.assertFalse(data['virtual'])
|
||||||
|
|
||||||
|
# By default, parts are not purchaseable
|
||||||
|
self.assertFalse(data['purchaseable'])
|
||||||
|
|
||||||
|
# Set the default 'purchaseable' status to True
|
||||||
|
InvenTreeSetting.set_setting(
|
||||||
|
'PART_PURCHASEABLE',
|
||||||
|
True,
|
||||||
|
self.user
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(url, {
|
||||||
|
'name': 'all defaults',
|
||||||
|
'description': 'my test part 2',
|
||||||
|
'category': 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Part should now be purchaseable by default
|
||||||
|
self.assertTrue(response.data['purchaseable'])
|
||||||
|
|
||||||
|
# "default" values should not be used if the value is specified
|
||||||
|
response = self.client.post(url, {
|
||||||
|
'name': 'all defaults',
|
||||||
|
'description': 'my test part 2',
|
||||||
|
'category': 1,
|
||||||
|
'active': False,
|
||||||
|
'purchaseable': False,
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertFalse(response.data['active'])
|
||||||
|
self.assertFalse(response.data['purchaseable'])
|
||||||
|
|
||||||
|
|
||||||
|
class PartDetailTests(InvenTreeAPITestCase):
|
||||||
|
"""
|
||||||
|
Test that we can create / edit / delete Part objects via the API
|
||||||
|
"""
|
||||||
|
|
||||||
|
fixtures = [
|
||||||
|
'category',
|
||||||
|
'part',
|
||||||
|
'location',
|
||||||
|
'bom',
|
||||||
|
'test_templates',
|
||||||
|
]
|
||||||
|
|
||||||
|
roles = [
|
||||||
|
'part.change',
|
||||||
|
'part.add',
|
||||||
|
'part.delete',
|
||||||
|
'part_category.change',
|
||||||
|
'part_category.add',
|
||||||
|
]
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
def test_part_operations(self):
|
||||||
|
n = Part.objects.count()
|
||||||
|
|
||||||
|
# Create a part
|
||||||
|
response = self.client.post(
|
||||||
|
reverse('api-part-list'),
|
||||||
|
{
|
||||||
|
'name': 'my test api part',
|
||||||
|
'description': 'a part created with the API',
|
||||||
|
'category': 1,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
|
||||||
|
pk = response.data['pk']
|
||||||
|
|
||||||
|
# Check that a new part has been added
|
||||||
|
self.assertEqual(Part.objects.count(), n + 1)
|
||||||
|
|
||||||
|
part = Part.objects.get(pk=pk)
|
||||||
|
|
||||||
|
self.assertEqual(part.name, 'my test api part')
|
||||||
|
|
||||||
|
# Edit the part
|
||||||
|
url = reverse('api-part-detail', kwargs={'pk': pk})
|
||||||
|
|
||||||
|
# Let's change the name of the part
|
||||||
|
|
||||||
|
response = self.client.patch(url, {
|
||||||
|
'name': 'a new better name',
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data['pk'], pk)
|
||||||
|
self.assertEqual(response.data['name'], 'a new better name')
|
||||||
|
|
||||||
|
part = Part.objects.get(pk=pk)
|
||||||
|
|
||||||
|
# Name has been altered
|
||||||
|
self.assertEqual(part.name, 'a new better name')
|
||||||
|
|
||||||
|
# Part count should not have changed
|
||||||
|
self.assertEqual(Part.objects.count(), n + 1)
|
||||||
|
|
||||||
|
# Now, try to set the name to the *same* value
|
||||||
|
# 2021-06-22 this test is to check that the "duplicate part" checks don't do strange things
|
||||||
|
response = self.client.patch(url, {
|
||||||
|
'name': 'a new better name',
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# Try to remove the part
|
||||||
|
response = self.client.delete(url)
|
||||||
|
|
||||||
|
# As the part is 'active' we cannot delete it
|
||||||
|
self.assertEqual(response.status_code, 405)
|
||||||
|
|
||||||
|
# So, let's make it not active
|
||||||
|
response = self.patch(url, {'active': False}, expected_code=200)
|
||||||
|
|
||||||
|
response = self.client.delete(url)
|
||||||
|
self.assertEqual(response.status_code, 204)
|
||||||
|
|
||||||
|
# Part count should have reduced
|
||||||
|
self.assertEqual(Part.objects.count(), n)
|
||||||
|
|
||||||
|
def test_duplicates(self):
|
||||||
|
"""
|
||||||
|
Check that trying to create 'duplicate' parts results in errors
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Create a part
|
||||||
|
response = self.client.post(reverse('api-part-list'), {
|
||||||
|
'name': 'part',
|
||||||
|
'description': 'description',
|
||||||
|
'IPN': 'IPN-123',
|
||||||
|
'category': 1,
|
||||||
|
'revision': 'A',
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
|
||||||
|
n = Part.objects.count()
|
||||||
|
|
||||||
|
# Check that we cannot create a duplicate in a different category
|
||||||
|
response = self.client.post(reverse('api-part-list'), {
|
||||||
|
'name': 'part',
|
||||||
|
'description': 'description',
|
||||||
|
'IPN': 'IPN-123',
|
||||||
|
'category': 2,
|
||||||
|
'revision': 'A',
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
# Check that only 1 matching part exists
|
||||||
|
parts = Part.objects.filter(
|
||||||
|
name='part',
|
||||||
|
description='description',
|
||||||
|
IPN='IPN-123'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(parts.count(), 1)
|
||||||
|
|
||||||
|
# A new part should *not* have been created
|
||||||
|
self.assertEqual(Part.objects.count(), n)
|
||||||
|
|
||||||
|
# But a different 'revision' *can* be created
|
||||||
|
response = self.client.post(reverse('api-part-list'), {
|
||||||
|
'name': 'part',
|
||||||
|
'description': 'description',
|
||||||
|
'IPN': 'IPN-123',
|
||||||
|
'category': 2,
|
||||||
|
'revision': 'B',
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
self.assertEqual(Part.objects.count(), n + 1)
|
||||||
|
|
||||||
|
# Now, check that we cannot *change* an existing part to conflict
|
||||||
|
pk = response.data['pk']
|
||||||
|
|
||||||
|
url = reverse('api-part-detail', kwargs={'pk': pk})
|
||||||
|
|
||||||
|
# Attempt to alter the revision code
|
||||||
|
response = self.client.patch(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'revision': 'A',
|
||||||
|
},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
# But we *can* change it to a unique revision code
|
||||||
|
response = self.client.patch(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'revision': 'C',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_image_upload(self):
|
||||||
|
"""
|
||||||
|
Test that we can upload an image to the part API
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.assignRole('part.add')
|
||||||
|
|
||||||
|
# Create a new part
|
||||||
|
response = self.client.post(
|
||||||
|
reverse('api-part-list'),
|
||||||
|
{
|
||||||
|
'name': 'imagine',
|
||||||
|
'description': 'All the people',
|
||||||
|
'category': 1,
|
||||||
|
},
|
||||||
|
expected_code=201
|
||||||
|
)
|
||||||
|
|
||||||
|
pk = response.data['pk']
|
||||||
|
|
||||||
|
url = reverse('api-part-detail', kwargs={'pk': pk})
|
||||||
|
|
||||||
|
p = Part.objects.get(pk=pk)
|
||||||
|
|
||||||
|
# Part should not have an image!
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
print(p.image.file)
|
||||||
|
|
||||||
|
# Create a custom APIClient for file uploads
|
||||||
|
# Ref: https://stackoverflow.com/questions/40453947/how-to-generate-a-file-upload-test-request-with-django-rest-frameworks-apireq
|
||||||
|
upload_client = APIClient()
|
||||||
|
upload_client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
|
# Try to upload a non-image file
|
||||||
|
with open('dummy_image.txt', 'w') as dummy_image:
|
||||||
|
dummy_image.write('hello world')
|
||||||
|
|
||||||
|
with open('dummy_image.txt', 'rb') as dummy_image:
|
||||||
|
response = upload_client.patch(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'image': dummy_image,
|
||||||
|
},
|
||||||
|
format='multipart',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
# Now try to upload a valid image file
|
||||||
|
img = PIL.Image.new('RGB', (128, 128), color='red')
|
||||||
|
img.save('dummy_image.jpg')
|
||||||
|
|
||||||
|
with open('dummy_image.jpg', 'rb') as dummy_image:
|
||||||
|
response = upload_client.patch(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'image': dummy_image,
|
||||||
|
},
|
||||||
|
format='multipart',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# And now check that the image has been set
|
||||||
|
p = Part.objects.get(pk=pk)
|
||||||
|
|
||||||
|
|
||||||
class PartAPIAggregationTest(InvenTreeAPITestCase):
|
class PartAPIAggregationTest(InvenTreeAPITestCase):
|
||||||
"""
|
"""
|
||||||
@ -319,6 +630,8 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
|
|||||||
# Add a new part
|
# Add a new part
|
||||||
self.part = Part.objects.create(
|
self.part = Part.objects.create(
|
||||||
name='Banana',
|
name='Banana',
|
||||||
|
description='This is a banana',
|
||||||
|
category=PartCategory.objects.get(pk=1),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create some stock items associated with the part
|
# Create some stock items associated with the part
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
import django.core.exceptions as django_exceptions
|
import django.core.exceptions as django_exceptions
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
from .models import Part, BomItem
|
from .models import Part, BomItem
|
||||||
|
|
||||||
@ -11,11 +12,16 @@ class BomItemTest(TestCase):
|
|||||||
'part',
|
'part',
|
||||||
'location',
|
'location',
|
||||||
'bom',
|
'bom',
|
||||||
|
'company',
|
||||||
|
'supplier_part',
|
||||||
|
'part_pricebreaks',
|
||||||
|
'price_breaks',
|
||||||
]
|
]
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.bob = Part.objects.get(id=100)
|
self.bob = Part.objects.get(id=100)
|
||||||
self.orphan = Part.objects.get(name='Orphan')
|
self.orphan = Part.objects.get(name='Orphan')
|
||||||
|
self.r1 = Part.objects.get(name='R_2K2_0805')
|
||||||
|
|
||||||
def test_str(self):
|
def test_str(self):
|
||||||
b = BomItem.objects.get(id=1)
|
b = BomItem.objects.get(id=1)
|
||||||
@ -111,3 +117,10 @@ class BomItemTest(TestCase):
|
|||||||
item.validate_hash()
|
item.validate_hash()
|
||||||
|
|
||||||
self.assertNotEqual(h1, h2)
|
self.assertNotEqual(h1, h2)
|
||||||
|
|
||||||
|
def test_pricing(self):
|
||||||
|
self.bob.get_price(1)
|
||||||
|
self.assertEqual(self.bob.get_bom_price_range(1, internal=True), (Decimal(84.5), Decimal(89.5)))
|
||||||
|
# remove internal price for R_2K2_0805
|
||||||
|
self.r1.internal_price_breaks.delete()
|
||||||
|
self.assertEqual(self.bob.get_bom_price_range(1, internal=True), (Decimal(82.5), Decimal(87.5)))
|
||||||
|
@ -23,7 +23,7 @@ class TestParams(TestCase):
|
|||||||
def test_str(self):
|
def test_str(self):
|
||||||
|
|
||||||
t1 = PartParameterTemplate.objects.get(pk=1)
|
t1 = PartParameterTemplate.objects.get(pk=1)
|
||||||
self.assertEquals(str(t1), 'Length (mm)')
|
self.assertEqual(str(t1), 'Length (mm)')
|
||||||
|
|
||||||
p1 = PartParameter.objects.get(pk=1)
|
p1 = PartParameter.objects.get(pk=1)
|
||||||
self.assertEqual(str(p1), 'M2x4 LPHS : Length = 4mm')
|
self.assertEqual(str(p1), 'M2x4 LPHS : Length = 4mm')
|
||||||
|
@ -11,7 +11,7 @@ from django.core.exceptions import ValidationError
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from .models import Part, PartTestTemplate
|
from .models import Part, PartCategory, PartTestTemplate
|
||||||
from .models import rename_part_image, match_part_names
|
from .models import rename_part_image, match_part_names
|
||||||
from .templatetags import inventree_extras
|
from .templatetags import inventree_extras
|
||||||
|
|
||||||
@ -51,6 +51,7 @@ class PartTest(TestCase):
|
|||||||
'category',
|
'category',
|
||||||
'part',
|
'part',
|
||||||
'location',
|
'location',
|
||||||
|
'part_pricebreaks'
|
||||||
]
|
]
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@ -77,6 +78,61 @@ class PartTest(TestCase):
|
|||||||
p = Part.objects.get(pk=100)
|
p = Part.objects.get(pk=100)
|
||||||
self.assertEqual(str(p), "BOB | Bob | A2 - Can we build it?")
|
self.assertEqual(str(p), "BOB | Bob | A2 - Can we build it?")
|
||||||
|
|
||||||
|
def test_duplicate(self):
|
||||||
|
"""
|
||||||
|
Test that we cannot create a "duplicate" Part
|
||||||
|
"""
|
||||||
|
|
||||||
|
n = Part.objects.count()
|
||||||
|
|
||||||
|
cat = PartCategory.objects.get(pk=1)
|
||||||
|
|
||||||
|
Part.objects.create(
|
||||||
|
category=cat,
|
||||||
|
name='part',
|
||||||
|
description='description',
|
||||||
|
IPN='IPN',
|
||||||
|
revision='A',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(Part.objects.count(), n + 1)
|
||||||
|
|
||||||
|
part = Part(
|
||||||
|
category=cat,
|
||||||
|
name='part',
|
||||||
|
description='description',
|
||||||
|
IPN='IPN',
|
||||||
|
revision='A',
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
part.validate_unique()
|
||||||
|
|
||||||
|
try:
|
||||||
|
part.save()
|
||||||
|
self.assertTrue(False)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.assertEqual(Part.objects.count(), n + 1)
|
||||||
|
|
||||||
|
# But we should be able to create a part with a different revision
|
||||||
|
part_2 = Part.objects.create(
|
||||||
|
category=cat,
|
||||||
|
name='part',
|
||||||
|
description='description',
|
||||||
|
IPN='IPN',
|
||||||
|
revision='B',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(Part.objects.count(), n + 2)
|
||||||
|
|
||||||
|
# Now, check that we cannot *change* part_2 to conflict
|
||||||
|
part_2.revision = 'A'
|
||||||
|
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
part_2.validate_unique()
|
||||||
|
|
||||||
def test_metadata(self):
|
def test_metadata(self):
|
||||||
self.assertEqual(self.r1.name, 'R_2K2_0805')
|
self.assertEqual(self.r1.name, 'R_2K2_0805')
|
||||||
self.assertEqual(self.r1.get_absolute_url(), '/part/3/')
|
self.assertEqual(self.r1.get_absolute_url(), '/part/3/')
|
||||||
@ -113,6 +169,22 @@ class PartTest(TestCase):
|
|||||||
|
|
||||||
self.assertTrue(len(matches) > 0)
|
self.assertTrue(len(matches) > 0)
|
||||||
|
|
||||||
|
def test_sell_pricing(self):
|
||||||
|
# check that the sell pricebreaks were loaded
|
||||||
|
self.assertTrue(self.r1.has_price_breaks)
|
||||||
|
self.assertEqual(self.r1.price_breaks.count(), 2)
|
||||||
|
# check that the sell pricebreaks work
|
||||||
|
self.assertEqual(float(self.r1.get_price(1)), 0.15)
|
||||||
|
self.assertEqual(float(self.r1.get_price(10)), 1.0)
|
||||||
|
|
||||||
|
def test_internal_pricing(self):
|
||||||
|
# check that the sell pricebreaks were loaded
|
||||||
|
self.assertTrue(self.r1.has_internal_price_breaks)
|
||||||
|
self.assertEqual(self.r1.internal_price_breaks.count(), 2)
|
||||||
|
# check that the sell pricebreaks work
|
||||||
|
self.assertEqual(float(self.r1.get_internal_price(1)), 0.08)
|
||||||
|
self.assertEqual(float(self.r1.get_internal_price(10)), 0.5)
|
||||||
|
|
||||||
|
|
||||||
class TestTemplateTest(TestCase):
|
class TestTemplateTest(TestCase):
|
||||||
|
|
||||||
@ -260,21 +332,24 @@ class PartSettingsTest(TestCase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Create a part
|
# Create a part
|
||||||
Part.objects.create(name='Hello', description='A thing', IPN='IPN123')
|
Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='A')
|
||||||
|
|
||||||
# Attempt to create a duplicate item (should fail)
|
# Attempt to create a duplicate item (should fail)
|
||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
Part.objects.create(name='Hello', description='A thing', IPN='IPN123')
|
part = Part(name='Hello', description='A thing', IPN='IPN123', revision='A')
|
||||||
|
part.validate_unique()
|
||||||
|
|
||||||
# Attempt to create item with duplicate IPN (should be allowed by default)
|
# Attempt to create item with duplicate IPN (should be allowed by default)
|
||||||
Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='B')
|
Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='B')
|
||||||
|
|
||||||
# And attempt again with the same values (should fail)
|
# And attempt again with the same values (should fail)
|
||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='B')
|
part = Part(name='Hello', description='A thing', IPN='IPN123', revision='B')
|
||||||
|
part.validate_unique()
|
||||||
|
|
||||||
# Now update the settings so duplicate IPN values are *not* allowed
|
# Now update the settings so duplicate IPN values are *not* allowed
|
||||||
InvenTreeSetting.set_setting('PART_ALLOW_DUPLICATE_IPN', False, self.user)
|
InvenTreeSetting.set_setting('PART_ALLOW_DUPLICATE_IPN', False, self.user)
|
||||||
|
|
||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='C')
|
part = Part(name='Hello', description='A thing', IPN='IPN123', revision='C')
|
||||||
|
part.full_clean()
|
||||||
|
@ -29,6 +29,12 @@ sale_price_break_urls = [
|
|||||||
url(r'^(?P<pk>\d+)/delete/', views.PartSalePriceBreakDelete.as_view(), name='sale-price-break-delete'),
|
url(r'^(?P<pk>\d+)/delete/', views.PartSalePriceBreakDelete.as_view(), name='sale-price-break-delete'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
internal_price_break_urls = [
|
||||||
|
url(r'^new/', views.PartInternalPriceBreakCreate.as_view(), name='internal-price-break-create'),
|
||||||
|
url(r'^(?P<pk>\d+)/edit/', views.PartInternalPriceBreakEdit.as_view(), name='internal-price-break-edit'),
|
||||||
|
url(r'^(?P<pk>\d+)/delete/', views.PartInternalPriceBreakDelete.as_view(), name='internal-price-break-delete'),
|
||||||
|
]
|
||||||
|
|
||||||
part_parameter_urls = [
|
part_parameter_urls = [
|
||||||
url(r'^template/new/', views.PartParameterTemplateCreate.as_view(), name='part-param-template-create'),
|
url(r'^template/new/', views.PartParameterTemplateCreate.as_view(), name='part-param-template-create'),
|
||||||
url(r'^template/(?P<pk>\d+)/edit/', views.PartParameterTemplateEdit.as_view(), name='part-param-template-edit'),
|
url(r'^template/(?P<pk>\d+)/edit/', views.PartParameterTemplateEdit.as_view(), name='part-param-template-edit'),
|
||||||
@ -65,6 +71,7 @@ part_detail_urls = [
|
|||||||
url(r'^orders/?', views.PartDetail.as_view(template_name='part/orders.html'), name='part-orders'),
|
url(r'^orders/?', views.PartDetail.as_view(template_name='part/orders.html'), name='part-orders'),
|
||||||
url(r'^sales-orders/', views.PartDetail.as_view(template_name='part/sales_orders.html'), name='part-sales-orders'),
|
url(r'^sales-orders/', views.PartDetail.as_view(template_name='part/sales_orders.html'), name='part-sales-orders'),
|
||||||
url(r'^sale-prices/', views.PartDetail.as_view(template_name='part/sale_prices.html'), name='part-sale-prices'),
|
url(r'^sale-prices/', views.PartDetail.as_view(template_name='part/sale_prices.html'), name='part-sale-prices'),
|
||||||
|
url(r'^internal-prices/', views.PartDetail.as_view(template_name='part/internal_prices.html'), name='part-internal-prices'),
|
||||||
url(r'^tests/', views.PartDetail.as_view(template_name='part/part_tests.html'), name='part-test-templates'),
|
url(r'^tests/', views.PartDetail.as_view(template_name='part/part_tests.html'), name='part-test-templates'),
|
||||||
url(r'^track/?', views.PartDetail.as_view(template_name='part/track.html'), name='part-track'),
|
url(r'^track/?', views.PartDetail.as_view(template_name='part/track.html'), name='part-track'),
|
||||||
url(r'^related-parts/?', views.PartDetail.as_view(template_name='part/related.html'), name='part-related'),
|
url(r'^related-parts/?', views.PartDetail.as_view(template_name='part/related.html'), name='part-related'),
|
||||||
@ -121,6 +128,10 @@ part_urls = [
|
|||||||
# Create a new part
|
# Create a new part
|
||||||
url(r'^new/?', views.PartCreate.as_view(), name='part-create'),
|
url(r'^new/?', views.PartCreate.as_view(), name='part-create'),
|
||||||
|
|
||||||
|
# Upload a part
|
||||||
|
url(r'^import/', views.PartImport.as_view(), name='part-import'),
|
||||||
|
url(r'^import-api/', views.PartImportAjax.as_view(), name='api-part-import'),
|
||||||
|
|
||||||
# Create a new BOM item
|
# Create a new BOM item
|
||||||
url(r'^bom/new/?', views.BomItemCreate.as_view(), name='bom-item-create'),
|
url(r'^bom/new/?', views.BomItemCreate.as_view(), name='bom-item-create'),
|
||||||
|
|
||||||
@ -145,6 +156,9 @@ part_urls = [
|
|||||||
# Part price breaks
|
# Part price breaks
|
||||||
url(r'^sale-price/', include(sale_price_break_urls)),
|
url(r'^sale-price/', include(sale_price_break_urls)),
|
||||||
|
|
||||||
|
# Part internal price breaks
|
||||||
|
url(r'^internal-price/', include(internal_price_break_urls)),
|
||||||
|
|
||||||
# Part test templates
|
# Part test templates
|
||||||
url(r'^test-template/', include([
|
url(r'^test-template/', include([
|
||||||
url(r'^new/', views.PartTestTemplateCreate.as_view(), name='part-test-template-create'),
|
url(r'^new/', views.PartTestTemplateCreate.as_view(), name='part-test-template-create'),
|
||||||
|
@ -17,6 +17,7 @@ from django.views.generic import DetailView, ListView, FormView, UpdateView
|
|||||||
from django.forms.models import model_to_dict
|
from django.forms.models import model_to_dict
|
||||||
from django.forms import HiddenInput, CheckboxInput
|
from django.forms import HiddenInput, CheckboxInput
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib import messages
|
||||||
|
|
||||||
from moneyed import CURRENCIES
|
from moneyed import CURRENCIES
|
||||||
from djmoney.contrib.exchange.models import convert_money
|
from djmoney.contrib.exchange.models import convert_money
|
||||||
@ -36,10 +37,14 @@ from .models import PartCategoryParameterTemplate
|
|||||||
from .models import BomItem
|
from .models import BomItem
|
||||||
from .models import match_part_names
|
from .models import match_part_names
|
||||||
from .models import PartTestTemplate
|
from .models import PartTestTemplate
|
||||||
from .models import PartSellPriceBreak
|
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||||
|
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
from company.models import SupplierPart
|
from company.models import SupplierPart
|
||||||
|
from common.files import FileManager
|
||||||
|
from common.views import FileManagementFormView, FileManagementAjaxView
|
||||||
|
|
||||||
|
from stock.models import StockLocation
|
||||||
|
|
||||||
import common.settings as inventree_settings
|
import common.settings as inventree_settings
|
||||||
|
|
||||||
@ -719,6 +724,168 @@ class PartCreate(AjaxCreateView):
|
|||||||
return initials
|
return initials
|
||||||
|
|
||||||
|
|
||||||
|
class PartImport(FileManagementFormView):
|
||||||
|
''' Part: Upload file, match to fields and import parts(using multi-Step form) '''
|
||||||
|
permission_required = 'part.add'
|
||||||
|
|
||||||
|
class PartFileManager(FileManager):
|
||||||
|
REQUIRED_HEADERS = [
|
||||||
|
'Name',
|
||||||
|
'Description',
|
||||||
|
]
|
||||||
|
|
||||||
|
OPTIONAL_MATCH_HEADERS = [
|
||||||
|
'Category',
|
||||||
|
'default_location',
|
||||||
|
'default_supplier',
|
||||||
|
]
|
||||||
|
|
||||||
|
OPTIONAL_HEADERS = [
|
||||||
|
'Keywords',
|
||||||
|
'IPN',
|
||||||
|
'Revision',
|
||||||
|
'Link',
|
||||||
|
'default_expiry',
|
||||||
|
'minimum_stock',
|
||||||
|
'Units',
|
||||||
|
'Notes',
|
||||||
|
]
|
||||||
|
|
||||||
|
name = 'part'
|
||||||
|
form_steps_template = [
|
||||||
|
'part/import_wizard/part_upload.html',
|
||||||
|
'part/import_wizard/match_fields.html',
|
||||||
|
'part/import_wizard/match_references.html',
|
||||||
|
]
|
||||||
|
form_steps_description = [
|
||||||
|
_("Upload File"),
|
||||||
|
_("Match Fields"),
|
||||||
|
_("Match References"),
|
||||||
|
]
|
||||||
|
|
||||||
|
form_field_map = {
|
||||||
|
'name': 'name',
|
||||||
|
'description': 'description',
|
||||||
|
'keywords': 'keywords',
|
||||||
|
'ipn': 'ipn',
|
||||||
|
'revision': 'revision',
|
||||||
|
'link': 'link',
|
||||||
|
'default_expiry': 'default_expiry',
|
||||||
|
'minimum_stock': 'minimum_stock',
|
||||||
|
'units': 'units',
|
||||||
|
'notes': 'notes',
|
||||||
|
'category': 'category',
|
||||||
|
'default_location': 'default_location',
|
||||||
|
'default_supplier': 'default_supplier',
|
||||||
|
}
|
||||||
|
file_manager_class = PartFileManager
|
||||||
|
|
||||||
|
def get_field_selection(self):
|
||||||
|
""" Fill the form fields for step 3 """
|
||||||
|
# fetch available elements
|
||||||
|
self.allowed_items = {}
|
||||||
|
self.matches = {}
|
||||||
|
|
||||||
|
self.allowed_items['Category'] = PartCategory.objects.all()
|
||||||
|
self.matches['Category'] = ['name__contains']
|
||||||
|
self.allowed_items['default_location'] = StockLocation.objects.all()
|
||||||
|
self.matches['default_location'] = ['name__contains']
|
||||||
|
self.allowed_items['default_supplier'] = SupplierPart.objects.all()
|
||||||
|
self.matches['default_supplier'] = ['SKU__contains']
|
||||||
|
|
||||||
|
# setup
|
||||||
|
self.file_manager.setup()
|
||||||
|
# collect submitted column indexes
|
||||||
|
col_ids = {}
|
||||||
|
for col in self.file_manager.HEADERS:
|
||||||
|
index = self.get_column_index(col)
|
||||||
|
if index >= 0:
|
||||||
|
col_ids[col] = index
|
||||||
|
|
||||||
|
# parse all rows
|
||||||
|
for row in self.rows:
|
||||||
|
# check each submitted column
|
||||||
|
for idx in col_ids:
|
||||||
|
data = row['data'][col_ids[idx]]['cell']
|
||||||
|
|
||||||
|
if idx in self.file_manager.OPTIONAL_MATCH_HEADERS:
|
||||||
|
try:
|
||||||
|
exact_match = self.allowed_items[idx].get(**{a: data for a in self.matches[idx]})
|
||||||
|
except (ValueError, self.allowed_items[idx].model.DoesNotExist, self.allowed_items[idx].model.MultipleObjectsReturned):
|
||||||
|
exact_match = None
|
||||||
|
|
||||||
|
row['match_options_' + idx] = self.allowed_items[idx]
|
||||||
|
row['match_' + idx] = exact_match
|
||||||
|
continue
|
||||||
|
|
||||||
|
# general fields
|
||||||
|
row[idx.lower()] = data
|
||||||
|
|
||||||
|
def done(self, form_list, **kwargs):
|
||||||
|
""" Create items """
|
||||||
|
items = self.get_clean_items()
|
||||||
|
|
||||||
|
import_done = 0
|
||||||
|
import_error = []
|
||||||
|
|
||||||
|
# Create Part instances
|
||||||
|
for part_data in items.values():
|
||||||
|
|
||||||
|
# set related parts
|
||||||
|
optional_matches = {}
|
||||||
|
for idx in self.file_manager.OPTIONAL_MATCH_HEADERS:
|
||||||
|
if idx.lower() in part_data:
|
||||||
|
try:
|
||||||
|
optional_matches[idx] = self.allowed_items[idx].get(pk=int(part_data[idx.lower()]))
|
||||||
|
except (ValueError, self.allowed_items[idx].model.DoesNotExist, self.allowed_items[idx].model.MultipleObjectsReturned):
|
||||||
|
optional_matches[idx] = None
|
||||||
|
else:
|
||||||
|
optional_matches[idx] = None
|
||||||
|
|
||||||
|
# add part
|
||||||
|
new_part = Part(
|
||||||
|
name=part_data.get('name', ''),
|
||||||
|
description=part_data.get('description', ''),
|
||||||
|
keywords=part_data.get('keywords', None),
|
||||||
|
IPN=part_data.get('ipn', None),
|
||||||
|
revision=part_data.get('revision', None),
|
||||||
|
link=part_data.get('link', None),
|
||||||
|
default_expiry=part_data.get('default_expiry', 0),
|
||||||
|
minimum_stock=part_data.get('minimum_stock', 0),
|
||||||
|
units=part_data.get('units', None),
|
||||||
|
notes=part_data.get('notes', None),
|
||||||
|
category=optional_matches['Category'],
|
||||||
|
default_location=optional_matches['default_location'],
|
||||||
|
default_supplier=optional_matches['default_supplier'],
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
new_part.save()
|
||||||
|
import_done += 1
|
||||||
|
except ValidationError as _e:
|
||||||
|
import_error.append(', '.join(set(_e.messages)))
|
||||||
|
|
||||||
|
# Set alerts
|
||||||
|
if import_done:
|
||||||
|
alert = f"<strong>{_('Part-Import')}</strong><br>{_('Imported {n} parts').format(n=import_done)}"
|
||||||
|
messages.success(self.request, alert)
|
||||||
|
if import_error:
|
||||||
|
error_text = '\n'.join([f'<li><strong>x{import_error.count(a)}</strong>: {a}</li>' for a in set(import_error)])
|
||||||
|
messages.error(self.request, f"<strong>{_('Some errors occured:')}</strong><br><ul>{error_text}</ul>")
|
||||||
|
|
||||||
|
return HttpResponseRedirect(reverse('part-index'))
|
||||||
|
|
||||||
|
|
||||||
|
class PartImportAjax(FileManagementAjaxView, PartImport):
|
||||||
|
ajax_form_steps_template = [
|
||||||
|
'part/import_wizard/ajax_part_upload.html',
|
||||||
|
'part/import_wizard/ajax_match_fields.html',
|
||||||
|
'part/import_wizard/ajax_match_references.html',
|
||||||
|
]
|
||||||
|
|
||||||
|
def validate(self, obj, form, **kwargs):
|
||||||
|
return PartImport.validate(self, self.steps.current, form, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class PartNotes(UpdateView):
|
class PartNotes(UpdateView):
|
||||||
""" View for editing the 'notes' field of a Part object.
|
""" View for editing the 'notes' field of a Part object.
|
||||||
Presents a live markdown editor.
|
Presents a live markdown editor.
|
||||||
@ -814,7 +981,7 @@ class PartPricingView(PartDetail):
|
|||||||
part = self.get_part()
|
part = self.get_part()
|
||||||
# Stock history
|
# Stock history
|
||||||
if part.total_stock > 1:
|
if part.total_stock > 1:
|
||||||
ret = []
|
price_history = []
|
||||||
stock = part.stock_entries(include_variants=False, in_stock=True) # .order_by('purchase_order__date')
|
stock = part.stock_entries(include_variants=False, in_stock=True) # .order_by('purchase_order__date')
|
||||||
stock = stock.prefetch_related('purchase_order', 'supplier_part')
|
stock = stock.prefetch_related('purchase_order', 'supplier_part')
|
||||||
|
|
||||||
@ -841,22 +1008,33 @@ class PartPricingView(PartDetail):
|
|||||||
line['date'] = stock_item.purchase_order.issue_date.strftime('%d.%m.%Y')
|
line['date'] = stock_item.purchase_order.issue_date.strftime('%d.%m.%Y')
|
||||||
else:
|
else:
|
||||||
line['date'] = stock_item.tracking_info.first().date.strftime('%d.%m.%Y')
|
line['date'] = stock_item.tracking_info.first().date.strftime('%d.%m.%Y')
|
||||||
ret.append(line)
|
price_history.append(line)
|
||||||
|
|
||||||
ctx['price_history'] = ret
|
ctx['price_history'] = price_history
|
||||||
|
|
||||||
# BOM Information for Pie-Chart
|
# BOM Information for Pie-Chart
|
||||||
bom_items = [{'name': str(a.sub_part), 'price': a.sub_part.get_price_range(quantity), 'q': a.quantity} for a in part.bom_items.all()]
|
if part.has_bom:
|
||||||
if [True for a in bom_items if len(set(a['price'])) == 2]:
|
# get internal price setting
|
||||||
ctx['bom_parts'] = [{
|
use_internal = InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False)
|
||||||
'name': a['name'],
|
ctx_bom_parts = []
|
||||||
'min_price': str((a['price'][0] * a['q']) / quantity),
|
# iterate over all bom-items
|
||||||
'max_price': str((a['price'][1] * a['q']) / quantity)} for a in bom_items]
|
for item in part.bom_items.all():
|
||||||
ctx['bom_pie_min'] = True
|
ctx_item = {'name': str(item.sub_part)}
|
||||||
else:
|
price, qty = item.sub_part.get_price_range(quantity, internal=use_internal), item.quantity
|
||||||
ctx['bom_parts'] = [{
|
|
||||||
'name': a['name'],
|
price_min, price_max = 0, 0
|
||||||
'price': str((a['price'][0] * a['q']) / quantity)} for a in bom_items]
|
if price: # check if price available
|
||||||
|
price_min = str((price[0] * qty) / quantity)
|
||||||
|
if len(set(price)) == 2: # min and max-price present
|
||||||
|
price_max = str((price[1] * qty) / quantity)
|
||||||
|
ctx['bom_pie_max'] = True # enable showing max prices in bom
|
||||||
|
|
||||||
|
ctx_item['max_price'] = price_min
|
||||||
|
ctx_item['min_price'] = price_max if price_max else price_min
|
||||||
|
ctx_bom_parts.append(ctx_item)
|
||||||
|
|
||||||
|
# add to global context
|
||||||
|
ctx['bom_parts'] = ctx_bom_parts
|
||||||
|
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
@ -2105,7 +2283,8 @@ class PartPricing(AjaxView):
|
|||||||
# BOM pricing information
|
# BOM pricing information
|
||||||
if part.bom_count > 0:
|
if part.bom_count > 0:
|
||||||
|
|
||||||
bom_price = part.get_bom_price_range(quantity)
|
use_internal = InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False)
|
||||||
|
bom_price = part.get_bom_price_range(quantity, internal=use_internal)
|
||||||
|
|
||||||
if bom_price is not None:
|
if bom_price is not None:
|
||||||
min_bom_price, max_bom_price = bom_price
|
min_bom_price, max_bom_price = bom_price
|
||||||
@ -2127,6 +2306,12 @@ class PartPricing(AjaxView):
|
|||||||
ctx['max_total_bom_price'] = max_bom_price
|
ctx['max_total_bom_price'] = max_bom_price
|
||||||
ctx['max_unit_bom_price'] = max_unit_bom_price
|
ctx['max_unit_bom_price'] = max_unit_bom_price
|
||||||
|
|
||||||
|
# internal part pricing information
|
||||||
|
internal_part_price = part.get_internal_price(quantity)
|
||||||
|
if internal_part_price is not None:
|
||||||
|
ctx['total_internal_part_price'] = round(internal_part_price, 3)
|
||||||
|
ctx['unit_internal_part_price'] = round(internal_part_price / quantity, 3)
|
||||||
|
|
||||||
# part pricing information
|
# part pricing information
|
||||||
part_price = part.get_price(quantity)
|
part_price = part.get_price(quantity)
|
||||||
if part_price is not None:
|
if part_price is not None:
|
||||||
@ -2764,7 +2949,7 @@ class PartSalePriceBreakCreate(AjaxCreateView):
|
|||||||
|
|
||||||
initials['part'] = self.get_part()
|
initials['part'] = self.get_part()
|
||||||
|
|
||||||
default_currency = settings.BASE_CURRENCY
|
default_currency = inventree_settings.currency_code_default()
|
||||||
currency = CURRENCIES.get(default_currency, None)
|
currency = CURRENCIES.get(default_currency, None)
|
||||||
|
|
||||||
if currency is not None:
|
if currency is not None:
|
||||||
@ -2794,3 +2979,29 @@ class PartSalePriceBreakDelete(AjaxDeleteView):
|
|||||||
model = PartSellPriceBreak
|
model = PartSellPriceBreak
|
||||||
ajax_form_title = _("Delete Price Break")
|
ajax_form_title = _("Delete Price Break")
|
||||||
ajax_template_name = "modal_delete_form.html"
|
ajax_template_name = "modal_delete_form.html"
|
||||||
|
|
||||||
|
|
||||||
|
class PartInternalPriceBreakCreate(PartSalePriceBreakCreate):
|
||||||
|
""" View for creating a internal price break for a part """
|
||||||
|
|
||||||
|
model = PartInternalPriceBreak
|
||||||
|
form_class = part_forms.EditPartInternalPriceBreakForm
|
||||||
|
ajax_form_title = _('Add Internal Price Break')
|
||||||
|
permission_required = 'roles.sales_order.add'
|
||||||
|
|
||||||
|
|
||||||
|
class PartInternalPriceBreakEdit(PartSalePriceBreakEdit):
|
||||||
|
""" View for editing a internal price break """
|
||||||
|
|
||||||
|
model = PartInternalPriceBreak
|
||||||
|
form_class = part_forms.EditPartInternalPriceBreakForm
|
||||||
|
ajax_form_title = _('Edit Internal Price Break')
|
||||||
|
permission_required = 'roles.sales_order.change'
|
||||||
|
|
||||||
|
|
||||||
|
class PartInternalPriceBreakDelete(PartSalePriceBreakDelete):
|
||||||
|
""" View for deleting a internal price break """
|
||||||
|
|
||||||
|
model = PartInternalPriceBreak
|
||||||
|
ajax_form_title = _("Delete Internal Price Break")
|
||||||
|
permission_required = 'roles.sales_order.delete'
|
||||||
|
@ -44,6 +44,13 @@ class LocationResource(ModelResource):
|
|||||||
StockLocation.objects.rebuild()
|
StockLocation.objects.rebuild()
|
||||||
|
|
||||||
|
|
||||||
|
class LocationInline(admin.TabularInline):
|
||||||
|
"""
|
||||||
|
Inline for sub-locations
|
||||||
|
"""
|
||||||
|
model = StockLocation
|
||||||
|
|
||||||
|
|
||||||
class LocationAdmin(ImportExportModelAdmin):
|
class LocationAdmin(ImportExportModelAdmin):
|
||||||
|
|
||||||
resource_class = LocationResource
|
resource_class = LocationResource
|
||||||
@ -52,6 +59,10 @@ class LocationAdmin(ImportExportModelAdmin):
|
|||||||
|
|
||||||
search_fields = ('name', 'description')
|
search_fields = ('name', 'description')
|
||||||
|
|
||||||
|
inlines = [
|
||||||
|
LocationInline,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class StockItemResource(ModelResource):
|
class StockItemResource(ModelResource):
|
||||||
""" Class for managing StockItem data import/export """
|
""" Class for managing StockItem data import/export """
|
||||||
|
@ -199,6 +199,7 @@ def update_history(apps, schema_editor):
|
|||||||
update_count += 1
|
update_count += 1
|
||||||
|
|
||||||
|
|
||||||
|
if update_count > 0:
|
||||||
print(f"\n==========================\nUpdated {update_count} StockItemHistory entries")
|
print(f"\n==========================\nUpdated {update_count} StockItemHistory entries")
|
||||||
|
|
||||||
|
|
||||||
|
78
InvenTree/stock/migrations/0064_auto_20210621_1724.py
Normal file
78
InvenTree/stock/migrations/0064_auto_20210621_1724.py
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
# Generated by Django 3.2.4 on 2021-06-21 07:24
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def extract_purchase_price(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Find instances of StockItem which do *not* have a purchase price set,
|
||||||
|
but which point to a PurchaseOrder where there *is* a purchase price set.
|
||||||
|
|
||||||
|
Then, assign *that* purchase price to original StockItem.
|
||||||
|
|
||||||
|
This is to address an issue where older versions of InvenTree
|
||||||
|
did not correctly copy purchase price information cross to the StockItem objects.
|
||||||
|
|
||||||
|
Current InvenTree version (as of 2021-06-21) copy this information across correctly,
|
||||||
|
so this one-time data migration should suffice.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Required database models
|
||||||
|
StockItem = apps.get_model('stock', 'stockitem')
|
||||||
|
PurchaseOrder = apps.get_model('order', 'purchaseorder')
|
||||||
|
PurchaseOrderLineItem = apps.get_model('order', 'purchaseorderlineitem')
|
||||||
|
Part = apps.get_model('part', 'part')
|
||||||
|
|
||||||
|
# Find all the StockItem objects without a purchase_price which point to a PurchaseOrder
|
||||||
|
items = StockItem.objects.filter(purchase_price=None).exclude(purchase_order=None)
|
||||||
|
|
||||||
|
if items.count() > 0:
|
||||||
|
print(f"Found {items.count()} stock items with missing purchase price information")
|
||||||
|
|
||||||
|
update_count = 0
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
|
||||||
|
part_id = item.part
|
||||||
|
|
||||||
|
po = item.purchase_order
|
||||||
|
|
||||||
|
# Look for a matching PurchaseOrderLineItem (with a price)
|
||||||
|
lines = PurchaseOrderLineItem.objects.filter(part__part=part_id, order=po)
|
||||||
|
|
||||||
|
if lines.exists():
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
if line.purchase_price is not None:
|
||||||
|
|
||||||
|
# Copy pricing information across
|
||||||
|
item.purchase_price = line.purchase_price
|
||||||
|
item.purchases_price_currency = line.purchase_price_currency
|
||||||
|
|
||||||
|
print(f"- Updating supplier price for {item.part.name} - {item.purchase_price} {item.purchase_price_currency}")
|
||||||
|
|
||||||
|
update_count += 1
|
||||||
|
|
||||||
|
item.save()
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
if update_count > 0:
|
||||||
|
print(f"Updated pricing for {update_count} stock items")
|
||||||
|
|
||||||
|
def reverse_operation(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
DO NOTHING!
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('stock', '0063_auto_20210511_2343'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(extract_purchase_price, reverse_code=reverse_operation)
|
||||||
|
]
|
25
InvenTree/stock/migrations/0065_auto_20210701_0509.py
Normal file
25
InvenTree/stock/migrations/0065_auto_20210701_0509.py
Normal file
@ -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 = [
|
||||||
|
('stock', '0064_auto_20210621_1724'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='stockitem',
|
||||||
|
name='purchase_price',
|
||||||
|
field=InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=4, default_currency='', help_text='Single unit purchase price at time of purchase', max_digits=19, null=True, verbose_name='Purchase Price'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='stockitem',
|
||||||
|
name='purchase_price_currency',
|
||||||
|
field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3),
|
||||||
|
),
|
||||||
|
]
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user