mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +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
|
||||
*.old
|
||||
|
||||
# Files used for testing
|
||||
dummy_image.*
|
||||
|
||||
# Sphinx files
|
||||
docs/_build
|
||||
|
||||
|
@ -18,6 +18,7 @@ class InvenTreeAPITestCase(APITestCase):
|
||||
email = 'test@testing.com'
|
||||
|
||||
superuser = False
|
||||
is_staff = True
|
||||
auto_login = True
|
||||
|
||||
# Set list of roles automatically associated with the user
|
||||
@ -40,8 +41,12 @@ class InvenTreeAPITestCase(APITestCase):
|
||||
|
||||
if self.superuser:
|
||||
self.user.is_superuser = True
|
||||
self.user.save()
|
||||
|
||||
if self.is_staff:
|
||||
self.user.is_staff = True
|
||||
|
||||
self.user.save()
|
||||
|
||||
for role in self.roles:
|
||||
self.assignRole(role)
|
||||
|
||||
@ -73,22 +78,50 @@ class InvenTreeAPITestCase(APITestCase):
|
||||
ruleset.save()
|
||||
break
|
||||
|
||||
def get(self, url, data={}, code=200):
|
||||
def get(self, url, data={}, expected_code=200):
|
||||
"""
|
||||
Issue a GET request
|
||||
"""
|
||||
|
||||
response = self.client.get(url, data, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, code)
|
||||
if expected_code is not None:
|
||||
self.assertEqual(response.status_code, expected_code)
|
||||
|
||||
return response
|
||||
|
||||
def post(self, url, data):
|
||||
def post(self, url, data, expected_code=None):
|
||||
"""
|
||||
Issue a POST request
|
||||
"""
|
||||
|
||||
response = self.client.post(url, data=data, format='json')
|
||||
|
||||
if expected_code is not None:
|
||||
self.assertEqual(response.status_code, expected_code)
|
||||
|
||||
return response
|
||||
|
||||
def delete(self, url, expected_code=None):
|
||||
"""
|
||||
Issue a DELETE request
|
||||
"""
|
||||
|
||||
response = self.client.delete(url)
|
||||
|
||||
if expected_code is not None:
|
||||
self.assertEqual(response.status_code, expected_code)
|
||||
|
||||
return response
|
||||
|
||||
def patch(self, url, data, files=None, expected_code=None):
|
||||
"""
|
||||
Issue a PATCH request
|
||||
"""
|
||||
|
||||
response = self.client.patch(url, data=data, files=files, format='json')
|
||||
|
||||
if expected_code is not None:
|
||||
self.assertEqual(response.status_code, expected_code)
|
||||
|
||||
return response
|
||||
|
@ -4,7 +4,6 @@ import logging
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.core.exceptions import AppRegistryNotReady
|
||||
from django.conf import settings
|
||||
|
||||
from InvenTree.ready import isInTestMode, canAppAccessDatabase
|
||||
import InvenTree.tasks
|
||||
@ -66,10 +65,11 @@ class InvenTreeConfig(AppConfig):
|
||||
from djmoney.contrib.exchange.models import ExchangeBackend
|
||||
from datetime import datetime, timedelta
|
||||
from InvenTree.tasks import update_exchange_rates
|
||||
from common.settings import currency_code_default
|
||||
except AppRegistryNotReady:
|
||||
pass
|
||||
|
||||
base_currency = settings.BASE_CURRENCY
|
||||
base_currency = currency_code_default()
|
||||
|
||||
update = False
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
from django.conf import settings as inventree_settings
|
||||
from common.settings import currency_code_default, currency_codes
|
||||
|
||||
from djmoney.contrib.exchange.backends.base import SimpleExchangeBackend
|
||||
|
||||
@ -22,8 +22,8 @@ class InvenTreeExchange(SimpleExchangeBackend):
|
||||
return {
|
||||
}
|
||||
|
||||
def update_rates(self, base_currency=inventree_settings.BASE_CURRENCY):
|
||||
def update_rates(self, base_currency=currency_code_default()):
|
||||
|
||||
symbols = ','.join(inventree_settings.CURRENCIES)
|
||||
symbols = ','.join(currency_codes())
|
||||
|
||||
super().update_rates(base=base_currency, symbols=symbols)
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
import sys
|
||||
|
||||
from .validators import allowable_url_schemes
|
||||
|
||||
@ -13,8 +14,11 @@ from django.core import validators
|
||||
from django import forms
|
||||
|
||||
from decimal import Decimal
|
||||
from djmoney.models.fields import MoneyField as ModelMoneyField
|
||||
from djmoney.forms.fields import MoneyField
|
||||
|
||||
import InvenTree.helpers
|
||||
import common.settings
|
||||
|
||||
|
||||
class InvenTreeURLFormField(FormURLField):
|
||||
@ -34,6 +38,42 @@ class InvenTreeURLField(models.URLField):
|
||||
})
|
||||
|
||||
|
||||
def money_kwargs():
|
||||
""" returns the database settings for MoneyFields """
|
||||
kwargs = {}
|
||||
kwargs['currency_choices'] = common.settings.currency_code_mappings()
|
||||
kwargs['default_currency'] = common.settings.currency_code_default
|
||||
return kwargs
|
||||
|
||||
|
||||
class InvenTreeModelMoneyField(ModelMoneyField):
|
||||
""" custom MoneyField for clean migrations while using dynamic currency settings """
|
||||
def __init__(self, **kwargs):
|
||||
# detect if creating migration
|
||||
if 'makemigrations' in sys.argv:
|
||||
# remove currency information for a clean migration
|
||||
kwargs['default_currency'] = ''
|
||||
kwargs['currency_choices'] = []
|
||||
else:
|
||||
# set defaults
|
||||
kwargs.update(money_kwargs())
|
||||
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def formfield(self, **kwargs):
|
||||
""" override form class to use own function """
|
||||
kwargs['form_class'] = InvenTreeMoneyField
|
||||
return super().formfield(**kwargs)
|
||||
|
||||
|
||||
class InvenTreeMoneyField(MoneyField):
|
||||
""" custom MoneyField for clean migrations while using dynamic currency settings """
|
||||
def __init__(self, *args, **kwargs):
|
||||
# override initial values with the real info from database
|
||||
kwargs.update(money_kwargs())
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class DatePickerFormField(forms.DateField):
|
||||
"""
|
||||
Custom date-picker field
|
||||
|
@ -21,6 +21,9 @@ import InvenTree.version
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
from .settings import MEDIA_URL, STATIC_URL
|
||||
from common.settings import currency_code_default
|
||||
|
||||
from djmoney.money import Money
|
||||
|
||||
|
||||
def getSetting(key, backup_value=None):
|
||||
@ -247,6 +250,22 @@ def decimal2string(d):
|
||||
return s.rstrip("0").rstrip(".")
|
||||
|
||||
|
||||
def decimal2money(d, currency=None):
|
||||
"""
|
||||
Format a Decimal number as Money
|
||||
|
||||
Args:
|
||||
d: A python Decimal object
|
||||
currency: Currency of the input amount, defaults to default currency in settings
|
||||
|
||||
Returns:
|
||||
A Money object from the input(s)
|
||||
"""
|
||||
if not currency:
|
||||
currency = currency_code_default()
|
||||
return Money(d, currency)
|
||||
|
||||
|
||||
def WrapWithQuotes(text, quote='"'):
|
||||
""" Wrap the supplied text with quotes
|
||||
|
||||
|
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
|
||||
|
||||
|
||||
def canAppAccessDatabase():
|
||||
def canAppAccessDatabase(allow_test=False):
|
||||
"""
|
||||
Returns True if the apps.py file can access database records.
|
||||
|
||||
@ -26,19 +26,23 @@ def canAppAccessDatabase():
|
||||
'flush',
|
||||
'loaddata',
|
||||
'dumpdata',
|
||||
'makemirations',
|
||||
'makemigrations',
|
||||
'migrate',
|
||||
'check',
|
||||
'mediarestore',
|
||||
'shell',
|
||||
'createsuperuser',
|
||||
'wait_for_db',
|
||||
'prerender',
|
||||
'rebuild',
|
||||
'collectstatic',
|
||||
'makemessages',
|
||||
'compilemessages',
|
||||
]
|
||||
|
||||
if not allow_test:
|
||||
# Override for testing mode?
|
||||
excluded_commands.append('test')
|
||||
|
||||
for cmd in excluded_commands:
|
||||
if cmd in sys.argv:
|
||||
return False
|
||||
|
@ -2,16 +2,19 @@
|
||||
Serializers used in various InvenTree apps
|
||||
"""
|
||||
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
|
||||
from rest_framework import serializers
|
||||
from rest_framework.utils import model_meta
|
||||
from rest_framework.fields import empty
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
@ -39,18 +42,103 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
|
||||
but also ensures that the underlying model class data are checked on validation.
|
||||
"""
|
||||
|
||||
def validate(self, data):
|
||||
""" Perform serializer validation.
|
||||
def __init__(self, instance=None, data=empty, **kwargs):
|
||||
|
||||
# self.instance = instance
|
||||
|
||||
# If instance is None, we are creating a new instance
|
||||
if instance is None and data is not empty:
|
||||
|
||||
# Required to side-step immutability of a QueryDict
|
||||
data = data.copy()
|
||||
|
||||
# Add missing fields which have default values
|
||||
ModelClass = self.Meta.model
|
||||
|
||||
fields = model_meta.get_field_info(ModelClass)
|
||||
|
||||
for field_name, field in fields.fields.items():
|
||||
|
||||
"""
|
||||
Update the field IF (and ONLY IF):
|
||||
- The field has a specified default value
|
||||
- The field does not already have a value set
|
||||
"""
|
||||
if field.has_default() and field_name not in data:
|
||||
|
||||
value = field.default
|
||||
|
||||
# Account for callable functions
|
||||
if callable(value):
|
||||
try:
|
||||
value = value()
|
||||
except:
|
||||
continue
|
||||
|
||||
data[field_name] = value
|
||||
|
||||
super().__init__(instance, data, **kwargs)
|
||||
|
||||
def get_initial(self):
|
||||
"""
|
||||
Construct initial data for the serializer.
|
||||
Use the 'default' values specified by the django model definition
|
||||
"""
|
||||
|
||||
initials = super().get_initial().copy()
|
||||
|
||||
# Are we creating a new instance?
|
||||
if self.instance is None:
|
||||
ModelClass = self.Meta.model
|
||||
|
||||
fields = model_meta.get_field_info(ModelClass)
|
||||
|
||||
for field_name, field in fields.fields.items():
|
||||
|
||||
if field.has_default() and field_name not in initials:
|
||||
|
||||
value = field.default
|
||||
|
||||
# Account for callable functions
|
||||
if callable(value):
|
||||
try:
|
||||
value = value()
|
||||
except:
|
||||
continue
|
||||
|
||||
initials[field_name] = value
|
||||
|
||||
return initials
|
||||
|
||||
def run_validation(self, data=empty):
|
||||
"""
|
||||
Perform serializer validation.
|
||||
In addition to running validators on the serializer fields,
|
||||
this class ensures that the underlying model is also validated.
|
||||
"""
|
||||
|
||||
# Run any native validation checks first (may throw an ValidationError)
|
||||
data = super(serializers.ModelSerializer, self).validate(data)
|
||||
# Run any native validation checks first (may raise a ValidationError)
|
||||
data = super().run_validation(data)
|
||||
|
||||
# Now ensure the underlying model is correct
|
||||
instance = self.Meta.model(**data)
|
||||
instance.clean()
|
||||
|
||||
if not hasattr(self, 'instance') or self.instance is None:
|
||||
# No instance exists (we are creating a new one)
|
||||
instance = self.Meta.model(**data)
|
||||
else:
|
||||
# Instance already exists (we are updating!)
|
||||
instance = self.instance
|
||||
|
||||
# Update instance fields
|
||||
for attr, value in data.items():
|
||||
setattr(instance, attr, value)
|
||||
|
||||
# Run a 'full_clean' on the model.
|
||||
# Note that by default, DRF does *not* perform full model validation!
|
||||
try:
|
||||
instance.full_clean()
|
||||
except (ValidationError, DjangoValidationError) as exc:
|
||||
raise ValidationError(detail=serializers.as_serializer_error(exc))
|
||||
|
||||
return data
|
||||
|
||||
@ -82,3 +170,17 @@ class InvenTreeAttachmentSerializerField(serializers.FileField):
|
||||
return None
|
||||
|
||||
return os.path.join(str(settings.MEDIA_URL), str(value))
|
||||
|
||||
|
||||
class InvenTreeImageSerializerField(serializers.ImageField):
|
||||
"""
|
||||
Custom image serializer.
|
||||
On upload, validate that the file is a valid image file
|
||||
"""
|
||||
|
||||
def to_representation(self, value):
|
||||
|
||||
if not value:
|
||||
return None
|
||||
|
||||
return os.path.join(str(settings.MEDIA_URL), str(value))
|
||||
|
@ -23,6 +23,7 @@ import moneyed
|
||||
|
||||
import yaml
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.contrib.messages import constants as messages
|
||||
|
||||
|
||||
def _is_true(x):
|
||||
@ -97,7 +98,7 @@ DOCKER = _is_true(get_setting(
|
||||
# Configure logging settings
|
||||
log_level = get_setting(
|
||||
'INVENTREE_LOG_LEVEL',
|
||||
CONFIG.get('log_level', 'DEBUG')
|
||||
CONFIG.get('log_level', 'WARNING')
|
||||
)
|
||||
|
||||
logging.basicConfig(
|
||||
@ -521,10 +522,6 @@ for currency in CURRENCIES:
|
||||
print(f"Currency code '{currency}' is not supported")
|
||||
sys.exit(1)
|
||||
|
||||
BASE_CURRENCY = get_setting(
|
||||
'INVENTREE_BASE_CURRENCY',
|
||||
CONFIG.get('base_currency', 'USD')
|
||||
)
|
||||
|
||||
# Custom currency exchange backend
|
||||
EXCHANGE_BACKEND = 'InvenTree.exchange.InvenTreeExchange'
|
||||
@ -611,3 +608,9 @@ IMPORT_EXPORT_USE_TRANSACTIONS = True
|
||||
INTERNAL_IPS = [
|
||||
'127.0.0.1',
|
||||
]
|
||||
|
||||
MESSAGE_TAGS = {
|
||||
messages.SUCCESS: 'alert alert-block alert-success',
|
||||
messages.ERROR: 'alert alert-block alert-danger',
|
||||
messages.INFO: 'alert alert-block alert-info',
|
||||
}
|
||||
|
@ -718,7 +718,7 @@
|
||||
position:relative;
|
||||
height: auto !important;
|
||||
max-height: calc(100vh - 200px) !important;
|
||||
overflow-y: scroll;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
|
@ -58,7 +58,7 @@ function inventreeFormDataUpload(url, data, options={}) {
|
||||
xhr.setRequestHeader('X-CSRFToken', csrftoken);
|
||||
},
|
||||
url: url,
|
||||
method: 'POST',
|
||||
method: options.method || 'POST',
|
||||
data: data,
|
||||
processData: false,
|
||||
contentType: false,
|
||||
|
@ -219,6 +219,7 @@ function enableDragAndDrop(element, url, options) {
|
||||
data - Other form data to upload
|
||||
success - Callback function in case of success
|
||||
error - Callback function in case of error
|
||||
method - HTTP method
|
||||
*/
|
||||
|
||||
data = options.data || {};
|
||||
@ -254,7 +255,8 @@ function enableDragAndDrop(element, url, options) {
|
||||
if (options.error) {
|
||||
options.error(xhr, status, error);
|
||||
}
|
||||
}
|
||||
},
|
||||
method: options.method || 'POST',
|
||||
}
|
||||
);
|
||||
} else {
|
||||
|
@ -4,9 +4,10 @@ Provides system status functionality checks.
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils import timezone
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
|
||||
from django_q.models import Success
|
||||
from django_q.monitor import Stat
|
||||
@ -34,7 +35,7 @@ def is_worker_running(**kwargs):
|
||||
Check to see if we have a result within the last 20 minutes
|
||||
"""
|
||||
|
||||
now = datetime.now()
|
||||
now = timezone.now()
|
||||
past = now - timedelta(minutes=20)
|
||||
|
||||
results = Success.objects.filter(
|
||||
@ -60,21 +61,21 @@ def is_email_configured():
|
||||
|
||||
# Display warning unless in test mode
|
||||
if not settings.TESTING:
|
||||
logger.warning("EMAIL_HOST is not configured")
|
||||
logger.debug("EMAIL_HOST is not configured")
|
||||
|
||||
if not settings.EMAIL_HOST_USER:
|
||||
configured = False
|
||||
|
||||
# Display warning unless in test mode
|
||||
if not settings.TESTING:
|
||||
logger.warning("EMAIL_HOST_USER is not configured")
|
||||
logger.debug("EMAIL_HOST_USER is not configured")
|
||||
|
||||
if not settings.EMAIL_HOST_PASSWORD:
|
||||
configured = False
|
||||
|
||||
# Display warning unless in test mode
|
||||
if not settings.TESTING:
|
||||
logger.warning("EMAIL_HOST_PASSWORD is not configured")
|
||||
logger.debug("EMAIL_HOST_PASSWORD is not configured")
|
||||
|
||||
return configured
|
||||
|
||||
|
@ -28,7 +28,7 @@ def schedule_task(taskname, **kwargs):
|
||||
try:
|
||||
from django_q.models import Schedule
|
||||
except (AppRegistryNotReady):
|
||||
logger.warning("Could not start background tasks - App registry not ready")
|
||||
logger.info("Could not start background tasks - App registry not ready")
|
||||
return
|
||||
|
||||
try:
|
||||
@ -80,7 +80,7 @@ def heartbeat():
|
||||
|
||||
try:
|
||||
from django_q.models import Success
|
||||
logger.warning("Could not perform heartbeat task - App registry not ready")
|
||||
logger.info("Could not perform heartbeat task - App registry not ready")
|
||||
except AppRegistryNotReady:
|
||||
return
|
||||
|
||||
@ -105,7 +105,7 @@ def delete_successful_tasks():
|
||||
try:
|
||||
from django_q.models import Success
|
||||
except AppRegistryNotReady:
|
||||
logger.warning("Could not perform 'delete_successful_tasks' - App registry not ready")
|
||||
logger.info("Could not perform 'delete_successful_tasks' - App registry not ready")
|
||||
return
|
||||
|
||||
threshold = datetime.now() - timedelta(days=30)
|
||||
@ -126,6 +126,7 @@ def check_for_updates():
|
||||
import common.models
|
||||
except AppRegistryNotReady:
|
||||
# Apps not yet loaded!
|
||||
logger.info("Could not perform 'check_for_updates' - App registry not ready")
|
||||
return
|
||||
|
||||
response = requests.get('https://api.github.com/repos/inventree/inventree/releases/latest')
|
||||
@ -169,9 +170,10 @@ def update_exchange_rates():
|
||||
try:
|
||||
from InvenTree.exchange import InvenTreeExchange
|
||||
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
|
||||
from django.conf import settings
|
||||
from common.settings import currency_code_default, currency_codes
|
||||
except AppRegistryNotReady:
|
||||
# Apps not yet loaded!
|
||||
logger.info("Could not perform 'update_exchange_rates' - App registry not ready")
|
||||
return
|
||||
except:
|
||||
# Other error?
|
||||
@ -190,14 +192,14 @@ def update_exchange_rates():
|
||||
backend = InvenTreeExchange()
|
||||
print(f"Updating exchange rates from {backend.url}")
|
||||
|
||||
base = settings.BASE_CURRENCY
|
||||
base = currency_code_default()
|
||||
|
||||
print(f"Using base currency '{base}'")
|
||||
|
||||
backend.update_rates(base_currency=base)
|
||||
|
||||
# Remove any exchange rates which are not in the provided currencies
|
||||
Rate.objects.filter(backend="InvenTreeExchange").exclude(currency__in=settings.CURRENCIES).delete()
|
||||
Rate.objects.filter(backend="InvenTreeExchange").exclude(currency__in=currency_codes()).delete()
|
||||
|
||||
|
||||
def send_email(subject, body, recipients, from_email=None):
|
||||
|
@ -2,6 +2,11 @@
|
||||
|
||||
from rest_framework import status
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
@ -11,6 +16,87 @@ from users.models import RuleSet
|
||||
from base64 import b64encode
|
||||
|
||||
|
||||
class HTMLAPITests(TestCase):
|
||||
"""
|
||||
Test that we can access the REST API endpoints via the HTML interface.
|
||||
|
||||
History: Discovered on 2021-06-28 a bug in InvenTreeModelSerializer,
|
||||
which raised an AssertionError when using the HTML API interface,
|
||||
while the regular JSON interface continued to work as expected.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
# Create a user
|
||||
user = get_user_model()
|
||||
|
||||
self.user = user.objects.create_user(
|
||||
username='username',
|
||||
email='user@email.com',
|
||||
password='password'
|
||||
)
|
||||
|
||||
# Put the user into a group with the correct permissions
|
||||
group = Group.objects.create(name='mygroup')
|
||||
self.user.groups.add(group)
|
||||
|
||||
# Give the group *all* the permissions!
|
||||
for rule in group.rule_sets.all():
|
||||
rule.can_view = True
|
||||
rule.can_change = True
|
||||
rule.can_add = True
|
||||
rule.can_delete = True
|
||||
|
||||
rule.save()
|
||||
|
||||
self.client.login(username='username', password='password')
|
||||
|
||||
def test_part_api(self):
|
||||
url = reverse('api-part-list')
|
||||
|
||||
# Check JSON response
|
||||
response = self.client.get(url, HTTP_ACCEPT='application/json')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Check HTTP response
|
||||
response = self.client.get(url, HTTP_ACCEPT='text/html')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_build_api(self):
|
||||
url = reverse('api-build-list')
|
||||
|
||||
# Check JSON response
|
||||
response = self.client.get(url, HTTP_ACCEPT='application/json')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Check HTTP response
|
||||
response = self.client.get(url, HTTP_ACCEPT='text/html')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_stock_api(self):
|
||||
url = reverse('api-stock-list')
|
||||
|
||||
# Check JSON response
|
||||
response = self.client.get(url, HTTP_ACCEPT='application/json')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Check HTTP response
|
||||
response = self.client.get(url, HTTP_ACCEPT='text/html')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_company_list(self):
|
||||
url = reverse('api-company-list')
|
||||
|
||||
# Check JSON response
|
||||
response = self.client.get(url, HTTP_ACCEPT='application/json')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Check HTTP response
|
||||
response = self.client.get(url, HTTP_ACCEPT='text/html')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class APITests(InvenTreeAPITestCase):
|
||||
""" Tests for the InvenTree API """
|
||||
|
||||
@ -77,7 +163,7 @@ class APITests(InvenTreeAPITestCase):
|
||||
self.assertIn('version', data)
|
||||
self.assertIn('instance', data)
|
||||
|
||||
self.assertEquals('InvenTree', data['server'])
|
||||
self.assertEqual('InvenTree', data['server'])
|
||||
|
||||
def test_role_view(self):
|
||||
"""
|
||||
|
@ -5,8 +5,6 @@ from django.test import TestCase
|
||||
import django.core.exceptions as django_exceptions
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from djmoney.money import Money
|
||||
from djmoney.contrib.exchange.models import Rate, convert_money
|
||||
from djmoney.contrib.exchange.exceptions import MissingRate
|
||||
@ -22,6 +20,7 @@ from decimal import Decimal
|
||||
import InvenTree.tasks
|
||||
|
||||
from stock.models import StockLocation
|
||||
from common.settings import currency_codes
|
||||
|
||||
|
||||
class ValidatorTest(TestCase):
|
||||
@ -337,13 +336,11 @@ class CurrencyTests(TestCase):
|
||||
with self.assertRaises(MissingRate):
|
||||
convert_money(Money(100, 'AUD'), 'USD')
|
||||
|
||||
currencies = settings.CURRENCIES
|
||||
|
||||
InvenTree.tasks.update_exchange_rates()
|
||||
|
||||
rates = Rate.objects.all()
|
||||
|
||||
self.assertEqual(rates.count(), len(currencies))
|
||||
self.assertEqual(rates.count(), len(currency_codes()))
|
||||
|
||||
# Now that we have some exchange rate information, we can perform conversions
|
||||
|
||||
|
@ -8,21 +8,27 @@ import re
|
||||
|
||||
import common.models
|
||||
|
||||
INVENTREE_SW_VERSION = "0.2.4 pre"
|
||||
INVENTREE_SW_VERSION = "0.2.5 pre"
|
||||
|
||||
INVENTREE_API_VERSION = 6
|
||||
|
||||
"""
|
||||
Increment thi API version number whenever there is a significant change to the API that any clients need to know about
|
||||
|
||||
v3 -> 2021-05-22:
|
||||
- The updated StockItem "history tracking" now uses a different interface
|
||||
v6 -> 2021-06-23
|
||||
- Part and Company images can now be directly uploaded via the REST API
|
||||
|
||||
v5 -> 2021-06-21
|
||||
- Adds API interface for manufacturer part parameters
|
||||
|
||||
v4 -> 2021-06-01
|
||||
- BOM items can now accept "variant stock" to be assigned against them
|
||||
- Many slight API tweaks were needed to get this to work properly!
|
||||
|
||||
"""
|
||||
v3 -> 2021-05-22:
|
||||
- The updated StockItem "history tracking" now uses a different interface
|
||||
|
||||
INVENTREE_API_VERSION = 4
|
||||
"""
|
||||
|
||||
|
||||
def inventreeInstanceName():
|
||||
|
@ -12,7 +12,6 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django.template.loader import render_to_string
|
||||
from django.http import HttpResponse, JsonResponse, HttpResponseRedirect
|
||||
from django.urls import reverse_lazy
|
||||
from django.conf import settings
|
||||
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
|
||||
@ -21,6 +20,7 @@ from django.views.generic import ListView, DetailView, CreateView, FormView, Del
|
||||
from django.views.generic.base import RedirectView, TemplateView
|
||||
|
||||
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
|
||||
from common.settings import currency_code_default, currency_codes
|
||||
|
||||
from part.models import Part, PartCategory
|
||||
from stock.models import StockLocation, StockItem
|
||||
@ -337,7 +337,7 @@ class AjaxMixin(InvenTreeRoleMixin):
|
||||
# Do nothing by default
|
||||
pass
|
||||
|
||||
def renderJsonResponse(self, request, form=None, data={}, context=None):
|
||||
def renderJsonResponse(self, request, form=None, data=None, context=None):
|
||||
""" Render a JSON response based on specific class context.
|
||||
|
||||
Args:
|
||||
@ -349,6 +349,9 @@ class AjaxMixin(InvenTreeRoleMixin):
|
||||
Returns:
|
||||
JSON response object
|
||||
"""
|
||||
# a empty dict as default can be dangerous - set it here if empty
|
||||
if not data:
|
||||
data = {}
|
||||
|
||||
if not request.is_ajax():
|
||||
return HttpResponseRedirect('/')
|
||||
@ -817,8 +820,8 @@ class CurrencySettingsView(TemplateView):
|
||||
ctx = super().get_context_data(**kwargs).copy()
|
||||
|
||||
ctx['settings'] = InvenTreeSetting.objects.all().order_by('key')
|
||||
ctx["base_currency"] = settings.BASE_CURRENCY
|
||||
ctx["currencies"] = settings.CURRENCIES
|
||||
ctx["base_currency"] = currency_code_default()
|
||||
ctx["currencies"] = currency_codes
|
||||
|
||||
ctx["rates"] = Rate.objects.filter(backend="InvenTreeExchange")
|
||||
|
||||
|
@ -165,6 +165,19 @@ class BuildItemList(generics.ListCreateAPIView):
|
||||
|
||||
serializer_class = BuildItemSerializer
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
|
||||
try:
|
||||
params = self.request.query_params
|
||||
|
||||
kwargs['part_detail'] = str2bool(params.get('part_detail', False))
|
||||
kwargs['build_detail'] = str2bool(params.get('build_detail', False))
|
||||
kwargs['location_detail'] = str2bool(params.get('location_detail', False))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
return self.serializer_class(*args, **kwargs)
|
||||
|
||||
def get_queryset(self):
|
||||
""" Override the queryset method,
|
||||
to allow filtering by stock_item.part
|
||||
|
@ -40,7 +40,8 @@ def assign_bom_items(apps, schema_editor):
|
||||
except BomItem.DoesNotExist:
|
||||
pass
|
||||
|
||||
print(f"Assigned BomItem for {count_valid}/{count_total} entries")
|
||||
if count_total > 0:
|
||||
print(f"Assigned BomItem for {count_valid}/{count_total} entries")
|
||||
|
||||
|
||||
def unassign_bom_items(apps, schema_editor):
|
||||
|
@ -13,7 +13,8 @@ from rest_framework import serializers
|
||||
from InvenTree.serializers import InvenTreeModelSerializer
|
||||
|
||||
from stock.serializers import StockItemSerializerBrief
|
||||
from part.serializers import PartBriefSerializer
|
||||
from stock.serializers import LocationSerializer
|
||||
from part.serializers import PartSerializer, PartBriefSerializer
|
||||
|
||||
from .models import Build, BuildItem
|
||||
|
||||
@ -99,22 +100,45 @@ class BuildItemSerializer(InvenTreeModelSerializer):
|
||||
|
||||
bom_part = serializers.IntegerField(source='bom_item.sub_part.pk', read_only=True)
|
||||
part = serializers.IntegerField(source='stock_item.part.pk', read_only=True)
|
||||
part_name = serializers.CharField(source='stock_item.part.full_name', read_only=True)
|
||||
part_thumb = serializers.CharField(source='getStockItemThumbnail', read_only=True)
|
||||
location = serializers.IntegerField(source='stock_item.location.pk', read_only=True)
|
||||
|
||||
# Extra (optional) detail fields
|
||||
part_detail = PartSerializer(source='stock_item.part', many=False, read_only=True)
|
||||
build_detail = BuildSerializer(source='build', many=False, read_only=True)
|
||||
stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True)
|
||||
location_detail = LocationSerializer(source='stock_item.location', read_only=True)
|
||||
|
||||
quantity = serializers.FloatField()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
build_detail = kwargs.pop('build_detail', False)
|
||||
part_detail = kwargs.pop('part_detail', False)
|
||||
location_detail = kwargs.pop('location_detail', False)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if not build_detail:
|
||||
self.fields.pop('build_detail')
|
||||
|
||||
if not part_detail:
|
||||
self.fields.pop('part_detail')
|
||||
|
||||
if not location_detail:
|
||||
self.fields.pop('location_detail')
|
||||
|
||||
class Meta:
|
||||
model = BuildItem
|
||||
fields = [
|
||||
'pk',
|
||||
'bom_part',
|
||||
'build',
|
||||
'build_detail',
|
||||
'install_into',
|
||||
'location',
|
||||
'location_detail',
|
||||
'part',
|
||||
'part_name',
|
||||
'part_thumb',
|
||||
'part_detail',
|
||||
'stock_item',
|
||||
'stock_item_detail',
|
||||
'quantity'
|
||||
|
@ -22,17 +22,19 @@ class FileManager:
|
||||
|
||||
# Fields which are used for item matching (only one of them is needed)
|
||||
ITEM_MATCH_HEADERS = []
|
||||
|
||||
|
||||
# Fields which would be helpful but are not required
|
||||
OPTIONAL_HEADERS = []
|
||||
|
||||
OPTIONAL_MATCH_HEADERS = []
|
||||
|
||||
EDITABLE_HEADERS = []
|
||||
|
||||
HEADERS = []
|
||||
|
||||
def __init__(self, file, name=None):
|
||||
""" Initialize the FileManager class with a user-uploaded file object """
|
||||
|
||||
|
||||
# Set name
|
||||
if name:
|
||||
self.name = name
|
||||
@ -71,47 +73,34 @@ class FileManager:
|
||||
raise ValidationError(_('Error reading file (incorrect dimension)'))
|
||||
except KeyError:
|
||||
raise ValidationError(_('Error reading file (data could be corrupted)'))
|
||||
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def process(self, file):
|
||||
""" Process file """
|
||||
|
||||
self.data = self.__class__.validate(file)
|
||||
|
||||
|
||||
def update_headers(self):
|
||||
""" Update headers """
|
||||
|
||||
self.HEADERS = self.REQUIRED_HEADERS + self.ITEM_MATCH_HEADERS + self.OPTIONAL_HEADERS
|
||||
|
||||
self.HEADERS = self.REQUIRED_HEADERS + self.ITEM_MATCH_HEADERS + self.OPTIONAL_MATCH_HEADERS + self.OPTIONAL_HEADERS
|
||||
|
||||
def setup(self):
|
||||
""" Setup headers depending on the file name """
|
||||
"""
|
||||
Setup headers
|
||||
should be overriden in usage to set the Different Headers
|
||||
"""
|
||||
|
||||
if not self.name:
|
||||
return
|
||||
|
||||
if self.name == 'order':
|
||||
self.REQUIRED_HEADERS = [
|
||||
'Quantity',
|
||||
]
|
||||
|
||||
self.ITEM_MATCH_HEADERS = [
|
||||
'Manufacturer_MPN',
|
||||
'Supplier_SKU',
|
||||
]
|
||||
|
||||
self.OPTIONAL_HEADERS = [
|
||||
'Purchase_Price',
|
||||
'Reference',
|
||||
'Notes',
|
||||
]
|
||||
|
||||
# Update headers
|
||||
self.update_headers()
|
||||
# Update headers
|
||||
self.update_headers()
|
||||
|
||||
def guess_header(self, header, threshold=80):
|
||||
""" Try to match a header (from the file) to a list of known headers
|
||||
|
||||
|
||||
Args:
|
||||
header - Header name to look for
|
||||
threshold - Match threshold for fuzzy search
|
||||
@ -145,7 +134,7 @@ class FileManager:
|
||||
return matches[0]['header']
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def columns(self):
|
||||
""" Return a list of headers for the thingy """
|
||||
headers = []
|
||||
|
@ -8,12 +8,7 @@ from __future__ import unicode_literals
|
||||
from django import forms
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from djmoney.forms.fields import MoneyField
|
||||
|
||||
from InvenTree.forms import HelperForm
|
||||
from InvenTree.helpers import clean_decimal
|
||||
|
||||
from common.settings import currency_code_default
|
||||
|
||||
from .files import FileManager
|
||||
from .models import InvenTreeSetting
|
||||
@ -32,7 +27,7 @@ class SettingEditForm(HelperForm):
|
||||
]
|
||||
|
||||
|
||||
class UploadFile(forms.Form):
|
||||
class UploadFileForm(forms.Form):
|
||||
""" Step 1 of FileManagementFormView """
|
||||
|
||||
file = forms.FileField(
|
||||
@ -70,9 +65,9 @@ class UploadFile(forms.Form):
|
||||
return file
|
||||
|
||||
|
||||
class MatchField(forms.Form):
|
||||
class MatchFieldForm(forms.Form):
|
||||
""" Step 2 of FileManagementFormView """
|
||||
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
# Get FileManager
|
||||
@ -88,7 +83,7 @@ class MatchField(forms.Form):
|
||||
columns = file_manager.columns()
|
||||
# Get headers choices
|
||||
headers_choices = [(header, header) for header in file_manager.HEADERS]
|
||||
|
||||
|
||||
# Create column fields
|
||||
for col in columns:
|
||||
field_name = col['name']
|
||||
@ -103,9 +98,9 @@ class MatchField(forms.Form):
|
||||
self.fields[field_name].initial = col['guess']
|
||||
|
||||
|
||||
class MatchItem(forms.Form):
|
||||
class MatchItemForm(forms.Form):
|
||||
""" Step 3 of FileManagementFormView """
|
||||
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
# Get FileManager
|
||||
@ -131,24 +126,41 @@ class MatchItem(forms.Form):
|
||||
for col in row['data']:
|
||||
# Get column matching
|
||||
col_guess = col['column'].get('guess', None)
|
||||
# Set field name
|
||||
field_name = col_guess.lower() + '-' + str(row['index'])
|
||||
|
||||
# check if field def was overriden
|
||||
overriden_field = self.get_special_field(col_guess, row, file_manager)
|
||||
if overriden_field:
|
||||
self.fields[field_name] = overriden_field
|
||||
|
||||
# Create input for required headers
|
||||
if col_guess in file_manager.REQUIRED_HEADERS:
|
||||
# Set field name
|
||||
field_name = col_guess.lower() + '-' + str(row['index'])
|
||||
elif col_guess in file_manager.REQUIRED_HEADERS:
|
||||
# Get value
|
||||
value = row.get(col_guess.lower(), '')
|
||||
# Set field input box
|
||||
if 'quantity' in col_guess.lower():
|
||||
self.fields[field_name] = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.NumberInput(attrs={
|
||||
'name': 'quantity' + str(row['index']),
|
||||
'class': 'numberinput', # form-control',
|
||||
'type': 'number',
|
||||
'min': '0',
|
||||
'step': 'any',
|
||||
'value': clean_decimal(row.get('quantity', '')),
|
||||
})
|
||||
)
|
||||
self.fields[field_name] = forms.CharField(
|
||||
required=True,
|
||||
initial=value,
|
||||
)
|
||||
|
||||
# Create item selection box
|
||||
elif col_guess in file_manager.OPTIONAL_MATCH_HEADERS:
|
||||
# Get item options
|
||||
item_options = [(option.id, option) for option in row['match_options_' + col_guess]]
|
||||
# Get item match
|
||||
item_match = row['match_' + col_guess]
|
||||
# Set field select box
|
||||
self.fields[field_name] = forms.ChoiceField(
|
||||
choices=[('', '-' * 10)] + item_options,
|
||||
required=False,
|
||||
widget=forms.Select(attrs={
|
||||
'class': 'select bomselect',
|
||||
})
|
||||
)
|
||||
# Update select box when match was found
|
||||
if item_match:
|
||||
self.fields[field_name].initial = item_match.id
|
||||
|
||||
# Create item selection box
|
||||
elif col_guess in file_manager.ITEM_MATCH_HEADERS:
|
||||
@ -176,22 +188,15 @@ class MatchItem(forms.Form):
|
||||
|
||||
# Optional entries
|
||||
elif col_guess in file_manager.OPTIONAL_HEADERS:
|
||||
# Set field name
|
||||
field_name = col_guess.lower() + '-' + str(row['index'])
|
||||
# Get value
|
||||
value = row.get(col_guess.lower(), '')
|
||||
# Set field input box
|
||||
if 'price' in col_guess.lower():
|
||||
self.fields[field_name] = MoneyField(
|
||||
label=_(col_guess),
|
||||
default_currency=currency_code_default(),
|
||||
decimal_places=5,
|
||||
max_digits=19,
|
||||
required=False,
|
||||
default_amount=clean_decimal(value),
|
||||
)
|
||||
else:
|
||||
self.fields[field_name] = forms.CharField(
|
||||
required=False,
|
||||
initial=value,
|
||||
)
|
||||
self.fields[field_name] = forms.CharField(
|
||||
required=False,
|
||||
initial=value,
|
||||
)
|
||||
|
||||
def get_special_field(self, col_guess, row, file_manager):
|
||||
""" Function to be overriden in inherited forms to add specific form settings """
|
||||
|
||||
return None
|
||||
|
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.conf import settings
|
||||
|
||||
from djmoney.models.fields import MoneyField
|
||||
from djmoney.settings import CURRENCY_CHOICES
|
||||
from djmoney.contrib.exchange.models import convert_money
|
||||
from djmoney.contrib.exchange.exceptions import MissingRate
|
||||
|
||||
from common.settings import currency_code_default
|
||||
import common.settings
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.core.validators import MinValueValidator, URLValidator
|
||||
@ -81,6 +81,13 @@ class InvenTreeSetting(models.Model):
|
||||
'default': '',
|
||||
},
|
||||
|
||||
'INVENTREE_DEFAULT_CURRENCY': {
|
||||
'name': _('Default Currency'),
|
||||
'description': _('Default currency'),
|
||||
'default': 'USD',
|
||||
'choices': CURRENCY_CHOICES,
|
||||
},
|
||||
|
||||
'INVENTREE_DOWNLOAD_FROM_URL': {
|
||||
'name': _('Download from URL'),
|
||||
'description': _('Allow download of remote images and files from external URL'),
|
||||
@ -205,6 +212,41 @@ class InvenTreeSetting(models.Model):
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'PART_SHOW_IMPORT': {
|
||||
'name': _('Show Import in Views'),
|
||||
'description': _('Display the import wizard in some part views'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'PART_SHOW_PRICE_IN_FORMS': {
|
||||
'name': _('Show Price in Forms'),
|
||||
'description': _('Display part price in some forms'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'PART_SHOW_RELATED': {
|
||||
'name': _('Show related parts'),
|
||||
'description': _('Display related parts for a part'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'PART_INTERNAL_PRICE': {
|
||||
'name': _('Internal Prices'),
|
||||
'description': _('Enable internal prices for parts'),
|
||||
'default': False,
|
||||
'validator': bool
|
||||
},
|
||||
|
||||
'PART_BOM_USE_INTERNAL_PRICE': {
|
||||
'name': _('Internal Price as BOM-Price'),
|
||||
'description': _('Use the internal price (if set) in BOM-price calculations'),
|
||||
'default': False,
|
||||
'validator': bool
|
||||
},
|
||||
|
||||
'REPORT_DEBUG_MODE': {
|
||||
'name': _('Debug Mode'),
|
||||
'description': _('Generate reports in debug mode (HTML output)'),
|
||||
@ -700,10 +742,9 @@ class PriceBreak(models.Model):
|
||||
help_text=_('Price break quantity'),
|
||||
)
|
||||
|
||||
price = MoneyField(
|
||||
price = InvenTree.fields.InvenTreeModelMoneyField(
|
||||
max_digits=19,
|
||||
decimal_places=4,
|
||||
default_currency=currency_code_default(),
|
||||
null=True,
|
||||
verbose_name=_('Price'),
|
||||
help_text=_('Unit price at specified quantity'),
|
||||
@ -726,7 +767,7 @@ class PriceBreak(models.Model):
|
||||
return converted.amount
|
||||
|
||||
|
||||
def get_price(instance, quantity, moq=True, multiples=True, currency=None):
|
||||
def get_price(instance, quantity, moq=True, multiples=True, currency=None, break_name: str = 'price_breaks'):
|
||||
""" Calculate the price based on quantity price breaks.
|
||||
|
||||
- Don't forget to add in flat-fee cost (base_cost field)
|
||||
@ -734,7 +775,10 @@ def get_price(instance, quantity, moq=True, multiples=True, currency=None):
|
||||
- If order multiples are to be observed, then we need to calculate based on that, too
|
||||
"""
|
||||
|
||||
price_breaks = instance.price_breaks.all()
|
||||
if hasattr(instance, break_name):
|
||||
price_breaks = getattr(instance, break_name).all()
|
||||
else:
|
||||
price_breaks = []
|
||||
|
||||
# No price break information available?
|
||||
if len(price_breaks) == 0:
|
||||
@ -753,10 +797,10 @@ def get_price(instance, quantity, moq=True, multiples=True, currency=None):
|
||||
|
||||
if currency is None:
|
||||
# Default currency selection
|
||||
currency = currency_code_default()
|
||||
currency = common.settings.currency_code_default()
|
||||
|
||||
pb_min = None
|
||||
for pb in instance.price_breaks.all():
|
||||
for pb in price_breaks:
|
||||
# Store smallest price break
|
||||
if not pb_min:
|
||||
pb_min = pb
|
||||
|
@ -6,9 +6,9 @@ User-configurable settings for the common app
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from moneyed import CURRENCIES
|
||||
from django.conf import settings
|
||||
|
||||
import common.models
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def currency_code_default():
|
||||
@ -16,7 +16,7 @@ def currency_code_default():
|
||||
Returns the default currency code (or USD if not specified)
|
||||
"""
|
||||
|
||||
code = settings.BASE_CURRENCY
|
||||
code = common.models.InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY')
|
||||
|
||||
if code not in CURRENCIES:
|
||||
code = 'USD'
|
||||
@ -24,6 +24,20 @@ def currency_code_default():
|
||||
return code
|
||||
|
||||
|
||||
def currency_code_mappings():
|
||||
"""
|
||||
Returns the current currency choices
|
||||
"""
|
||||
return [(a, a) for a in settings.CURRENCIES]
|
||||
|
||||
|
||||
def currency_codes():
|
||||
"""
|
||||
Returns the current currency codes
|
||||
"""
|
||||
return [a for a in settings.CURRENCIES]
|
||||
|
||||
|
||||
def stock_expiry_enabled():
|
||||
"""
|
||||
Returns True if the stock expiry feature is enabled
|
||||
|
@ -13,8 +13,9 @@ from django.conf import settings
|
||||
from django.core.files.storage import FileSystemStorage
|
||||
|
||||
from formtools.wizard.views import SessionWizardView
|
||||
from crispy_forms.helper import FormHelper
|
||||
|
||||
from InvenTree.views import AjaxUpdateView
|
||||
from InvenTree.views import AjaxUpdateView, AjaxView
|
||||
from InvenTree.helpers import str2bool
|
||||
|
||||
from . import models
|
||||
@ -117,7 +118,6 @@ class MultiStepFormView(SessionWizardView):
|
||||
form_steps_description: description for each form
|
||||
"""
|
||||
|
||||
form_list = []
|
||||
form_steps_template = []
|
||||
form_steps_description = []
|
||||
file_manager = None
|
||||
@ -126,10 +126,10 @@ class MultiStepFormView(SessionWizardView):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
""" Override init method to set media folder """
|
||||
super().__init__(*args, **kwargs)
|
||||
super().__init__(**kwargs)
|
||||
|
||||
self.process_media_folder()
|
||||
|
||||
|
||||
def process_media_folder(self):
|
||||
""" Process media folder """
|
||||
|
||||
@ -141,7 +141,7 @@ class MultiStepFormView(SessionWizardView):
|
||||
|
||||
def get_template_names(self):
|
||||
""" Select template """
|
||||
|
||||
|
||||
try:
|
||||
# Get template
|
||||
template = self.form_steps_template[self.steps.index]
|
||||
@ -152,7 +152,7 @@ class MultiStepFormView(SessionWizardView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
""" Update context data """
|
||||
|
||||
|
||||
# Retrieve current context
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
@ -176,9 +176,9 @@ class FileManagementFormView(MultiStepFormView):
|
||||
|
||||
name = None
|
||||
form_list = [
|
||||
('upload', forms.UploadFile),
|
||||
('fields', forms.MatchField),
|
||||
('items', forms.MatchItem),
|
||||
('upload', forms.UploadFileForm),
|
||||
('fields', forms.MatchFieldForm),
|
||||
('items', forms.MatchItemForm),
|
||||
]
|
||||
form_steps_description = [
|
||||
_("Upload File"),
|
||||
@ -188,11 +188,26 @@ class FileManagementFormView(MultiStepFormView):
|
||||
media_folder = 'file_upload/'
|
||||
extra_context_data = {}
|
||||
|
||||
def get_context_data(self, form, **kwargs):
|
||||
def __init__(self, *args, **kwargs):
|
||||
""" Initialize the FormView """
|
||||
|
||||
# Perform all checks and inits for MultiStepFormView
|
||||
super().__init__(self, *args, **kwargs)
|
||||
|
||||
# Check for file manager class
|
||||
if not hasattr(self, 'file_manager_class') and not issubclass(self.file_manager_class, FileManager):
|
||||
raise NotImplementedError('A subclass of a file manager class needs to be set!')
|
||||
|
||||
def get_context_data(self, form=None, **kwargs):
|
||||
""" Handle context data """
|
||||
|
||||
if form is None:
|
||||
form = self.get_form()
|
||||
|
||||
context = super().get_context_data(form=form, **kwargs)
|
||||
|
||||
if self.steps.current in ('fields', 'items'):
|
||||
|
||||
|
||||
# Get columns and row data
|
||||
self.columns = self.file_manager.columns()
|
||||
self.rows = self.file_manager.rows()
|
||||
@ -203,7 +218,7 @@ class FileManagementFormView(MultiStepFormView):
|
||||
elif self.steps.current == 'items':
|
||||
# Set form table data
|
||||
self.set_form_table_data(form=form)
|
||||
|
||||
|
||||
# Update context
|
||||
context.update({'rows': self.rows})
|
||||
context.update({'columns': self.columns})
|
||||
@ -227,7 +242,7 @@ class FileManagementFormView(MultiStepFormView):
|
||||
# Get file
|
||||
file = upload_files.get('upload-file', None)
|
||||
if file:
|
||||
self.file_manager = FileManager(file=file, name=self.name)
|
||||
self.file_manager = self.file_manager_class(file=file, name=self.name)
|
||||
|
||||
def get_form_kwargs(self, step=None):
|
||||
""" Update kwargs to dynamically build forms """
|
||||
@ -262,13 +277,22 @@ class FileManagementFormView(MultiStepFormView):
|
||||
self.get_form_table_data(data)
|
||||
self.set_form_table_data()
|
||||
self.get_field_selection()
|
||||
|
||||
|
||||
kwargs['row_data'] = self.rows
|
||||
|
||||
return kwargs
|
||||
|
||||
|
||||
return super().get_form_kwargs()
|
||||
|
||||
def get_form(self, step=None, data=None, files=None):
|
||||
""" add crispy-form helper to form """
|
||||
form = super().get_form(step=step, data=data, files=files)
|
||||
|
||||
form.helper = FormHelper()
|
||||
form.helper.form_show_labels = False
|
||||
|
||||
return form
|
||||
|
||||
def get_form_table_data(self, form_data):
|
||||
""" Extract table cell data from form data and fields.
|
||||
These data are used to maintain state between sessions.
|
||||
@ -327,7 +351,7 @@ class FileManagementFormView(MultiStepFormView):
|
||||
col_id = int(s[3])
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
|
||||
if row_id not in self.row_data:
|
||||
self.row_data[row_id] = {}
|
||||
|
||||
@ -362,19 +386,20 @@ class FileManagementFormView(MultiStepFormView):
|
||||
'name': self.column_names[idx],
|
||||
'guess': self.column_selections[idx],
|
||||
}
|
||||
|
||||
|
||||
cell_data = {
|
||||
'cell': item,
|
||||
'idx': idx,
|
||||
'column': column_data,
|
||||
}
|
||||
data.append(cell_data)
|
||||
|
||||
|
||||
row = {
|
||||
'index': row_idx,
|
||||
'data': data,
|
||||
'errors': {},
|
||||
}
|
||||
|
||||
self.rows.append(row)
|
||||
|
||||
# In the item selection step: update row data with mapping to form fields
|
||||
@ -414,6 +439,33 @@ class FileManagementFormView(MultiStepFormView):
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_clean_items(self):
|
||||
""" returns dict with all cleaned values """
|
||||
items = {}
|
||||
|
||||
for form_key, form_value in self.get_all_cleaned_data().items():
|
||||
# Split key from row value
|
||||
try:
|
||||
(field, idx) = form_key.split('-')
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
try:
|
||||
if idx not in items:
|
||||
# Insert into items
|
||||
items.update({
|
||||
idx: {
|
||||
self.form_field_map[field]: form_value,
|
||||
}
|
||||
})
|
||||
else:
|
||||
# Update items
|
||||
items[idx][self.form_field_map[field]] = form_value
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
return items
|
||||
|
||||
def check_field_selection(self, form):
|
||||
""" Check field matching """
|
||||
|
||||
@ -431,7 +483,7 @@ class FileManagementFormView(MultiStepFormView):
|
||||
if col in self.column_selections.values():
|
||||
part_match_found = True
|
||||
break
|
||||
|
||||
|
||||
# If not, notify user
|
||||
if not part_match_found:
|
||||
for col in self.file_manager.ITEM_MATCH_HEADERS:
|
||||
@ -451,7 +503,7 @@ class FileManagementFormView(MultiStepFormView):
|
||||
n = list(self.column_selections.values()).count(self.column_selections[col])
|
||||
if n > 1 and self.column_selections[col] not in duplicates:
|
||||
duplicates.append(self.column_selections[col])
|
||||
|
||||
|
||||
# Store extra context data
|
||||
self.extra_context_data = {
|
||||
'missing_columns': missing_columns,
|
||||
@ -497,3 +549,70 @@ class FileManagementFormView(MultiStepFormView):
|
||||
return self.render(form)
|
||||
|
||||
return super().post(*args, **kwargs)
|
||||
|
||||
|
||||
class FileManagementAjaxView(AjaxView):
|
||||
""" Use a FileManagementFormView as base for a AjaxView
|
||||
Inherit this class before inheriting the base FileManagementFormView
|
||||
|
||||
ajax_form_steps_template: templates for rendering ajax
|
||||
validate: function to validate the current form -> normally point to the same function in the base FileManagementFormView
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
# check if back-step button was selected
|
||||
wizard_back = self.request.POST.get('act-btn_back', None)
|
||||
if wizard_back:
|
||||
back_step_index = self.get_step_index() - 1
|
||||
self.storage.current_step = list(self.get_form_list().keys())[back_step_index]
|
||||
return self.renderJsonResponse(request, data={'form_valid': None})
|
||||
|
||||
# validate form
|
||||
form = self.get_form(data=self.request.POST, files=self.request.FILES)
|
||||
form_valid = self.validate(self.steps.current, form)
|
||||
|
||||
# check if valid
|
||||
if not form_valid:
|
||||
return self.renderJsonResponse(request, data={'form_valid': None})
|
||||
|
||||
# store the cleaned data and files.
|
||||
self.storage.set_step_data(self.steps.current, self.process_step(form))
|
||||
self.storage.set_step_files(self.steps.current, self.process_step_files(form))
|
||||
|
||||
# check if the current step is the last step
|
||||
if self.steps.current == self.steps.last:
|
||||
# call done - to process data, returned response is not used
|
||||
self.render_done(form)
|
||||
data = {'form_valid': True, 'success': _('Parts imported')}
|
||||
return self.renderJsonResponse(request, data=data)
|
||||
else:
|
||||
self.storage.current_step = self.steps.next
|
||||
|
||||
return self.renderJsonResponse(request, data={'form_valid': None})
|
||||
|
||||
def get(self, request):
|
||||
if 'reset' in request.GET:
|
||||
# reset form
|
||||
self.storage.reset()
|
||||
self.storage.current_step = self.steps.first
|
||||
return self.renderJsonResponse(request)
|
||||
|
||||
def renderJsonResponse(self, request, form=None, data={}, context=None):
|
||||
""" always set the right templates before rendering """
|
||||
self.setTemplate()
|
||||
return super().renderJsonResponse(request, form=form, data=data, context=context)
|
||||
|
||||
def get_data(self):
|
||||
data = super().get_data()
|
||||
data['hideErrorMessage'] = '1' # hide the error
|
||||
buttons = [{'name': 'back', 'title': _('Previous Step')}] if self.get_step_index() > 0 else []
|
||||
data['buttons'] = buttons # set buttons
|
||||
return data
|
||||
|
||||
def setTemplate(self):
|
||||
""" set template name and title """
|
||||
self.ajax_template_name = self.ajax_form_steps_template[self.get_step_index()]
|
||||
self.ajax_form_title = self.form_steps_description[self.get_step_index()]
|
||||
|
||||
def validate(self, obj, form, **kwargs):
|
||||
raise NotImplementedError('This function needs to be overridden!')
|
||||
|
@ -11,6 +11,7 @@ import import_export.widgets as widgets
|
||||
from .models import Company
|
||||
from .models import SupplierPart
|
||||
from .models import SupplierPriceBreak
|
||||
from .models import ManufacturerPart, ManufacturerPartParameter
|
||||
|
||||
from part.models import Part
|
||||
|
||||
@ -71,6 +72,92 @@ class SupplierPartAdmin(ImportExportModelAdmin):
|
||||
]
|
||||
|
||||
|
||||
class ManufacturerPartResource(ModelResource):
|
||||
"""
|
||||
Class for managing ManufacturerPart data import/export
|
||||
"""
|
||||
|
||||
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
|
||||
|
||||
part_name = Field(attribute='part__full_name', readonly=True)
|
||||
|
||||
manufacturer = Field(attribute='manufacturer', widget=widgets.ForeignKeyWidget(Company))
|
||||
|
||||
manufacturer_name = Field(attribute='manufacturer__name', readonly=True)
|
||||
|
||||
class Meta:
|
||||
model = ManufacturerPart
|
||||
skip_unchanged = True
|
||||
report_skipped = True
|
||||
clean_model_instances = True
|
||||
|
||||
|
||||
class ManufacturerPartParameterInline(admin.TabularInline):
|
||||
"""
|
||||
Inline for editing ManufacturerPartParameter objects,
|
||||
directly from the ManufacturerPart admin view.
|
||||
"""
|
||||
|
||||
model = ManufacturerPartParameter
|
||||
|
||||
|
||||
class SupplierPartInline(admin.TabularInline):
|
||||
"""
|
||||
Inline for the SupplierPart model
|
||||
"""
|
||||
|
||||
model = SupplierPart
|
||||
|
||||
|
||||
class ManufacturerPartAdmin(ImportExportModelAdmin):
|
||||
"""
|
||||
Admin class for ManufacturerPart model
|
||||
"""
|
||||
|
||||
resource_class = ManufacturerPartResource
|
||||
|
||||
list_display = ('part', 'manufacturer', 'MPN')
|
||||
|
||||
search_fields = [
|
||||
'manufacturer__name',
|
||||
'part__name',
|
||||
'MPN',
|
||||
]
|
||||
|
||||
inlines = [
|
||||
SupplierPartInline,
|
||||
ManufacturerPartParameterInline,
|
||||
]
|
||||
|
||||
|
||||
class ManufacturerPartParameterResource(ModelResource):
|
||||
"""
|
||||
Class for managing ManufacturerPartParameter data import/export
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = ManufacturerPartParameter
|
||||
skip_unchanged = True
|
||||
report_skipped = True
|
||||
clean_model_instance = True
|
||||
|
||||
|
||||
class ManufacturerPartParameterAdmin(ImportExportModelAdmin):
|
||||
"""
|
||||
Admin class for ManufacturerPartParameter model
|
||||
"""
|
||||
|
||||
resource_class = ManufacturerPartParameterResource
|
||||
|
||||
list_display = ('manufacturer_part', 'name', 'value')
|
||||
|
||||
search_fields = [
|
||||
'manufacturer_part__manufacturer__name',
|
||||
'name',
|
||||
'value'
|
||||
]
|
||||
|
||||
|
||||
class SupplierPriceBreakResource(ModelResource):
|
||||
""" Class for managing SupplierPriceBreak data import/export """
|
||||
|
||||
@ -103,3 +190,6 @@ class SupplierPriceBreakAdmin(ImportExportModelAdmin):
|
||||
admin.site.register(Company, CompanyAdmin)
|
||||
admin.site.register(SupplierPart, SupplierPartAdmin)
|
||||
admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin)
|
||||
|
||||
admin.site.register(ManufacturerPart, ManufacturerPartAdmin)
|
||||
admin.site.register(ManufacturerPartParameter, ManufacturerPartParameterAdmin)
|
||||
|
@ -15,11 +15,11 @@ from django.db.models import Q
|
||||
from InvenTree.helpers import str2bool
|
||||
|
||||
from .models import Company
|
||||
from .models import ManufacturerPart
|
||||
from .models import ManufacturerPart, ManufacturerPartParameter
|
||||
from .models import SupplierPart, SupplierPriceBreak
|
||||
|
||||
from .serializers import CompanySerializer
|
||||
from .serializers import ManufacturerPartSerializer
|
||||
from .serializers import ManufacturerPartSerializer, ManufacturerPartParameterSerializer
|
||||
from .serializers import SupplierPartSerializer, SupplierPriceBreakSerializer
|
||||
|
||||
|
||||
@ -103,17 +103,11 @@ class ManufacturerPartList(generics.ListCreateAPIView):
|
||||
|
||||
# Do we wish to include extra detail?
|
||||
try:
|
||||
kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', None))
|
||||
except AttributeError:
|
||||
pass
|
||||
params = self.request.query_params
|
||||
|
||||
try:
|
||||
kwargs['manufacturer_detail'] = str2bool(self.request.query_params.get('manufacturer_detail', None))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
try:
|
||||
kwargs['pretty'] = str2bool(self.request.query_params.get('pretty', None))
|
||||
kwargs['part_detail'] = str2bool(params.get('part_detail', None))
|
||||
kwargs['manufacturer_detail'] = str2bool(params.get('manufacturer_detail', None))
|
||||
kwargs['pretty'] = str2bool(params.get('pretty', None))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
@ -181,6 +175,86 @@ class ManufacturerPartDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
serializer_class = ManufacturerPartSerializer
|
||||
|
||||
|
||||
class ManufacturerPartParameterList(generics.ListCreateAPIView):
|
||||
"""
|
||||
API endpoint for list view of ManufacturerPartParamater model.
|
||||
"""
|
||||
|
||||
queryset = ManufacturerPartParameter.objects.all()
|
||||
serializer_class = ManufacturerPartParameterSerializer
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
|
||||
# Do we wish to include any extra detail?
|
||||
try:
|
||||
params = self.request.query_params
|
||||
|
||||
optional_fields = [
|
||||
'manufacturer_part_detail',
|
||||
]
|
||||
|
||||
for key in optional_fields:
|
||||
kwargs[key] = str2bool(params.get(key, None))
|
||||
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
kwargs['context'] = self.get_serializer_context()
|
||||
|
||||
return self.serializer_class(*args, **kwargs)
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""
|
||||
Custom filtering for the queryset
|
||||
"""
|
||||
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
params = self.request.query_params
|
||||
|
||||
# Filter by manufacturer?
|
||||
manufacturer = params.get('manufacturer', None)
|
||||
|
||||
if manufacturer is not None:
|
||||
queryset = queryset.filter(manufacturer_part__manufacturer=manufacturer)
|
||||
|
||||
# Filter by part?
|
||||
part = params.get('part', None)
|
||||
|
||||
if part is not None:
|
||||
queryset = queryset.filter(manufacturer_part__part=part)
|
||||
|
||||
return queryset
|
||||
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
filters.SearchFilter,
|
||||
filters.OrderingFilter,
|
||||
]
|
||||
|
||||
filter_fields = [
|
||||
'name',
|
||||
'value',
|
||||
'units',
|
||||
'manufacturer_part',
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
'name',
|
||||
'value',
|
||||
'units',
|
||||
]
|
||||
|
||||
|
||||
class ManufacturerPartParameterDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""
|
||||
API endpoint for detail view of ManufacturerPartParameter model
|
||||
"""
|
||||
|
||||
queryset = ManufacturerPartParameter.objects.all()
|
||||
serializer_class = ManufacturerPartParameterSerializer
|
||||
|
||||
|
||||
class SupplierPartList(generics.ListCreateAPIView):
|
||||
""" API endpoint for list view of SupplierPart object
|
||||
|
||||
@ -252,22 +326,11 @@ class SupplierPartList(generics.ListCreateAPIView):
|
||||
|
||||
# Do we wish to include extra detail?
|
||||
try:
|
||||
kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', None))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
try:
|
||||
kwargs['supplier_detail'] = str2bool(self.request.query_params.get('supplier_detail', None))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
try:
|
||||
kwargs['manufacturer_detail'] = str2bool(self.request.query_params.get('manufacturer_detail', None))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
try:
|
||||
kwargs['pretty'] = str2bool(self.request.query_params.get('pretty', None))
|
||||
params = self.request.query_params
|
||||
kwargs['part_detail'] = str2bool(params.get('part_detail', None))
|
||||
kwargs['supplier_detail'] = str2bool(params.get('supplier_detail', None))
|
||||
kwargs['manufacturer_detail'] = str2bool(params.get('manufacturer_detail', None))
|
||||
kwargs['pretty'] = str2bool(params.get('pretty', None))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
@ -333,6 +396,13 @@ class SupplierPriceBreakList(generics.ListCreateAPIView):
|
||||
|
||||
manufacturer_part_api_urls = [
|
||||
|
||||
url(r'^parameter/', include([
|
||||
url(r'^(?P<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'),
|
||||
|
||||
# Catch anything else
|
||||
|
@ -44,8 +44,6 @@ class CompanyConfig(AppConfig):
|
||||
company.image.render_variations(replace=False)
|
||||
except FileNotFoundError:
|
||||
logger.warning(f"Image file '{company.image}' missing")
|
||||
company.image = None
|
||||
company.save()
|
||||
except UnidentifiedImageError:
|
||||
logger.warning(f"Image file '{company.image}' is invalid")
|
||||
except (OperationalError, ProgrammingError):
|
||||
|
@ -52,3 +52,10 @@
|
||||
part: 2
|
||||
supplier: 2
|
||||
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 InvenTree.forms import HelperForm
|
||||
from InvenTree.fields import RoundingDecimalFormField
|
||||
from InvenTree.fields import InvenTreeMoneyField, RoundingDecimalFormField
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
import django.forms
|
||||
|
||||
import djmoney.settings
|
||||
from djmoney.forms.fields import MoneyField
|
||||
|
||||
from common.settings import currency_code_default
|
||||
|
||||
from .models import Company
|
||||
from .models import Company, ManufacturerPartParameter
|
||||
from .models import ManufacturerPart
|
||||
from .models import SupplierPart
|
||||
from .models import SupplierPriceBreak
|
||||
@ -105,6 +104,21 @@ class EditManufacturerPartForm(HelperForm):
|
||||
]
|
||||
|
||||
|
||||
class EditManufacturerPartParameterForm(HelperForm):
|
||||
"""
|
||||
Form for creating / editing a ManufacturerPartParameter object
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = ManufacturerPartParameter
|
||||
fields = [
|
||||
'manufacturer_part',
|
||||
'name',
|
||||
'value',
|
||||
'units',
|
||||
]
|
||||
|
||||
|
||||
class EditSupplierPartForm(HelperForm):
|
||||
""" Form for editing a SupplierPart object """
|
||||
|
||||
@ -114,9 +128,8 @@ class EditSupplierPartForm(HelperForm):
|
||||
'note': 'fa-pencil-alt',
|
||||
}
|
||||
|
||||
single_pricing = MoneyField(
|
||||
single_pricing = InvenTreeMoneyField(
|
||||
label=_('Single Price'),
|
||||
default_currency=currency_code_default(),
|
||||
help_text=_('Single quantity price'),
|
||||
decimal_places=4,
|
||||
max_digits=19,
|
||||
|
@ -71,7 +71,8 @@ def migrate_currencies(apps, schema_editor):
|
||||
|
||||
count += 1
|
||||
|
||||
print(f"Updated {count} SupplierPriceBreak rows")
|
||||
if count > 0:
|
||||
print(f"Updated {count} SupplierPriceBreak rows")
|
||||
|
||||
def reverse_currencies(apps, schema_editor):
|
||||
"""
|
||||
|
@ -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
|
||||
|
||||
|
||||
class ManufacturerPartParameter(models.Model):
|
||||
"""
|
||||
A ManufacturerPartParameter represents a key:value parameter for a MnaufacturerPart.
|
||||
|
||||
This is used to represent parmeters / properties for a particular manufacturer part.
|
||||
|
||||
Each parameter is a simple string (text) value.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
unique_together = ('manufacturer_part', 'name')
|
||||
|
||||
manufacturer_part = models.ForeignKey(
|
||||
ManufacturerPart,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='parameters',
|
||||
verbose_name=_('Manufacturer Part'),
|
||||
)
|
||||
|
||||
name = models.CharField(
|
||||
max_length=500,
|
||||
blank=False,
|
||||
verbose_name=_('Name'),
|
||||
help_text=_('Parameter name')
|
||||
)
|
||||
|
||||
value = models.CharField(
|
||||
max_length=500,
|
||||
blank=False,
|
||||
verbose_name=_('Value'),
|
||||
help_text=_('Parameter value')
|
||||
)
|
||||
|
||||
units = models.CharField(
|
||||
max_length=64,
|
||||
blank=True, null=True,
|
||||
verbose_name=_('Units'),
|
||||
help_text=_('Parameter units')
|
||||
)
|
||||
|
||||
|
||||
class SupplierPart(models.Model):
|
||||
""" Represents a unique part as provided by a Supplier
|
||||
Each SupplierPart is identified by a SKU (Supplier Part Number)
|
||||
|
@ -6,14 +6,15 @@ from rest_framework import serializers
|
||||
|
||||
from sql_util.utils import SubqueryCount
|
||||
|
||||
from .models import Company
|
||||
from .models import ManufacturerPart
|
||||
from .models import SupplierPart, SupplierPriceBreak
|
||||
|
||||
from InvenTree.serializers import InvenTreeModelSerializer
|
||||
from InvenTree.serializers import InvenTreeImageSerializerField
|
||||
|
||||
from part.serializers import PartBriefSerializer
|
||||
|
||||
from .models import Company
|
||||
from .models import ManufacturerPart, ManufacturerPartParameter
|
||||
from .models import SupplierPart, SupplierPriceBreak
|
||||
|
||||
|
||||
class CompanyBriefSerializer(InvenTreeModelSerializer):
|
||||
""" Serializer for Company object (limited detail) """
|
||||
@ -52,7 +53,7 @@ class CompanySerializer(InvenTreeModelSerializer):
|
||||
|
||||
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
||||
|
||||
image = serializers.CharField(source='get_thumbnail_url', read_only=True)
|
||||
image = InvenTreeImageSerializerField(required=False, allow_null=True)
|
||||
|
||||
parts_supplied = serializers.IntegerField(read_only=True)
|
||||
parts_manufactured = serializers.IntegerField(read_only=True)
|
||||
@ -124,6 +125,35 @@ class ManufacturerPartSerializer(InvenTreeModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class ManufacturerPartParameterSerializer(InvenTreeModelSerializer):
|
||||
"""
|
||||
Serializer for the ManufacturerPartParameter model
|
||||
"""
|
||||
|
||||
manufacturer_part_detail = ManufacturerPartSerializer(source='manufacturer_part', many=False, read_only=True)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
man_detail = kwargs.pop('manufacturer_part_detail', False)
|
||||
|
||||
super(ManufacturerPartParameterSerializer, self).__init__(*args, **kwargs)
|
||||
|
||||
if not man_detail:
|
||||
self.fields.pop('manufacturer_part_detail')
|
||||
|
||||
class Meta:
|
||||
model = ManufacturerPartParameter
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
'manufacturer_part',
|
||||
'manufacturer_part_detail',
|
||||
'name',
|
||||
'value',
|
||||
'units',
|
||||
]
|
||||
|
||||
|
||||
class SupplierPartSerializer(InvenTreeModelSerializer):
|
||||
""" Serializer for SupplierPart object """
|
||||
|
||||
|
@ -139,11 +139,17 @@
|
||||
|
||||
enableDragAndDrop(
|
||||
"#company-thumb",
|
||||
"{% url 'company-image' company.id %}",
|
||||
"{% url 'api-company-detail' company.id %}",
|
||||
{
|
||||
label: 'image',
|
||||
method: 'PATCH',
|
||||
success: function(data, status, xhr) {
|
||||
location.reload();
|
||||
|
||||
if (data.image) {
|
||||
$('#company-image').attr('src', data.image);
|
||||
} else {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
@ -7,7 +7,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Supplier Parts" %}
|
||||
{% trans "Suppliers" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block details %}
|
||||
@ -30,9 +30,44 @@
|
||||
|
||||
{% 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.super }}
|
||||
|
||||
$('#parameter-create').click(function() {
|
||||
launchModalForm(
|
||||
"{% url 'manufacturer-part-parameter-create' %}",
|
||||
{
|
||||
data: {
|
||||
manufacturer_part: {{ part.id }},
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$('#supplier-create').click(function () {
|
||||
launchModalForm(
|
||||
"{% url 'supplier-part-create' %}",
|
||||
@ -84,6 +119,16 @@ loadSupplierPartTable(
|
||||
}
|
||||
);
|
||||
|
||||
loadManufacturerPartParameterTable(
|
||||
"#parameter-table",
|
||||
"{% url 'api-manufacturer-part-parameter-list' %}",
|
||||
{
|
||||
params: {
|
||||
manufacturer_part: {{ part.id }},
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
linkButtonsToSelection($("#supplier-table"), ['#supplier-part-options'])
|
||||
|
||||
{% endblock %}
|
@ -50,10 +50,15 @@ class CompanyTest(InvenTreeAPITestCase):
|
||||
self.assertEqual(response.data['name'], 'ACME')
|
||||
|
||||
# Change the name of the company
|
||||
# Note we should not have the correct permissions (yet)
|
||||
data = response.data
|
||||
data['name'] = 'ACMOO'
|
||||
response = self.client.patch(url, data, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
response = self.client.patch(url, data, format='json', expected_code=400)
|
||||
|
||||
self.assignRole('company.change')
|
||||
|
||||
response = self.client.patch(url, data, format='json', expected_code=200)
|
||||
|
||||
self.assertEqual(response.data['name'], 'ACMOO')
|
||||
|
||||
def test_company_search(self):
|
||||
@ -119,7 +124,9 @@ class ManufacturerTest(InvenTreeAPITestCase):
|
||||
data = {
|
||||
'MPN': 'MPN-TEST-123',
|
||||
}
|
||||
|
||||
response = self.client.patch(url, data, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['MPN'], 'MPN-TEST-123')
|
||||
|
||||
|
@ -65,7 +65,7 @@ class CompanySimpleTest(TestCase):
|
||||
self.assertEqual(acme.supplied_part_count, 4)
|
||||
|
||||
self.assertTrue(appel.has_parts)
|
||||
self.assertEqual(appel.supplied_part_count, 3)
|
||||
self.assertEqual(appel.supplied_part_count, 4)
|
||||
|
||||
self.assertTrue(zerg.has_parts)
|
||||
self.assertEqual(zerg.supplied_part_count, 2)
|
||||
|
@ -53,20 +53,25 @@ price_break_urls = [
|
||||
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 = [
|
||||
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 = [
|
||||
|
@ -23,14 +23,14 @@ from InvenTree.views import AjaxCreateView, AjaxUpdateView, AjaxDeleteView
|
||||
from InvenTree.helpers import str2bool
|
||||
from InvenTree.views import InvenTreeRoleMixin
|
||||
|
||||
from .models import Company
|
||||
from .models import Company, ManufacturerPartParameter
|
||||
from .models import ManufacturerPart
|
||||
from .models import SupplierPart
|
||||
from .models import SupplierPriceBreak
|
||||
|
||||
from part.models import Part
|
||||
|
||||
from .forms import EditCompanyForm
|
||||
from .forms import EditCompanyForm, EditManufacturerPartParameterForm
|
||||
from .forms import CompanyImageForm
|
||||
from .forms import EditManufacturerPartForm
|
||||
from .forms import EditSupplierPartForm
|
||||
@ -504,6 +504,66 @@ class ManufacturerPartDelete(AjaxDeleteView):
|
||||
return self.renderJsonResponse(self.request, data=data, form=self.get_form())
|
||||
|
||||
|
||||
class ManufacturerPartParameterCreate(AjaxCreateView):
|
||||
"""
|
||||
View for creating a new ManufacturerPartParameter object
|
||||
"""
|
||||
|
||||
model = ManufacturerPartParameter
|
||||
form_class = EditManufacturerPartParameterForm
|
||||
ajax_form_title = _('Add Manufacturer Part Parameter')
|
||||
|
||||
def get_form(self):
|
||||
|
||||
form = super().get_form()
|
||||
|
||||
# Hide the manufacturer_part field if specified
|
||||
if form.initial.get('manufacturer_part', None):
|
||||
form.fields['manufacturer_part'].widget = HiddenInput()
|
||||
|
||||
return form
|
||||
|
||||
def get_initial(self):
|
||||
|
||||
initials = super().get_initial().copy()
|
||||
|
||||
manufacturer_part = self.get_param('manufacturer_part')
|
||||
|
||||
if manufacturer_part:
|
||||
try:
|
||||
initials['manufacturer_part'] = ManufacturerPartParameter.objects.get(pk=manufacturer_part)
|
||||
except (ValueError, ManufacturerPartParameter.DoesNotExist):
|
||||
pass
|
||||
|
||||
return initials
|
||||
|
||||
|
||||
class ManufacturerPartParameterEdit(AjaxUpdateView):
|
||||
"""
|
||||
View for editing a ManufacturerPartParameter object
|
||||
"""
|
||||
|
||||
model = ManufacturerPartParameter
|
||||
form_class = EditManufacturerPartParameterForm
|
||||
ajax_form_title = _('Edit Manufacturer Part Parameter')
|
||||
|
||||
def get_form(self):
|
||||
|
||||
form = super().get_form()
|
||||
|
||||
form.fields['manufacturer_part'].widget = HiddenInput()
|
||||
|
||||
return form
|
||||
|
||||
|
||||
class ManufacturerPartParameterDelete(AjaxDeleteView):
|
||||
"""
|
||||
View for deleting a ManufacturerPartParameter object
|
||||
"""
|
||||
|
||||
model = ManufacturerPartParameter
|
||||
|
||||
|
||||
class SupplierPartDetail(DetailView):
|
||||
""" Detail view for SupplierPart """
|
||||
model = SupplierPart
|
||||
@ -563,7 +623,8 @@ class SupplierPartEdit(AjaxUpdateView):
|
||||
supplier_part = self.get_object()
|
||||
|
||||
if supplier_part.manufacturer_part:
|
||||
initials['manufacturer'] = supplier_part.manufacturer_part.manufacturer.id
|
||||
if supplier_part.manufacturer_part.manufacturer:
|
||||
initials['manufacturer'] = supplier_part.manufacturer_part.manufacturer.id
|
||||
initials['MPN'] = supplier_part.manufacturer_part.MPN
|
||||
|
||||
return initials
|
||||
|
@ -22,9 +22,10 @@ from .models import PurchaseOrder, PurchaseOrderLineItem
|
||||
from .models import PurchaseOrderAttachment
|
||||
from .serializers import POSerializer, POLineItemSerializer, POAttachmentSerializer
|
||||
|
||||
from .models import SalesOrder, SalesOrderLineItem
|
||||
from .models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation
|
||||
from .models import SalesOrderAttachment
|
||||
from .serializers import SalesOrderSerializer, SOLineItemSerializer, SOAttachmentSerializer
|
||||
from .serializers import SalesOrderAllocationSerializer
|
||||
|
||||
|
||||
class POList(generics.ListCreateAPIView):
|
||||
@ -156,7 +157,7 @@ class POList(generics.ListCreateAPIView):
|
||||
ordering = '-creation_date'
|
||||
|
||||
|
||||
class PODetail(generics.RetrieveUpdateAPIView):
|
||||
class PODetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
""" API endpoint for detail view of a PurchaseOrder object """
|
||||
|
||||
queryset = PurchaseOrder.objects.all()
|
||||
@ -381,7 +382,7 @@ class SOList(generics.ListCreateAPIView):
|
||||
ordering = '-creation_date'
|
||||
|
||||
|
||||
class SODetail(generics.RetrieveUpdateAPIView):
|
||||
class SODetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""
|
||||
API endpoint for detail view of a SalesOrder object.
|
||||
"""
|
||||
@ -422,17 +423,11 @@ class SOLineItemList(generics.ListCreateAPIView):
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
|
||||
try:
|
||||
kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', False))
|
||||
except AttributeError:
|
||||
pass
|
||||
params = self.request.query_params
|
||||
|
||||
try:
|
||||
kwargs['order_detail'] = str2bool(self.request.query_params.get('order_detail', False))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
try:
|
||||
kwargs['allocations'] = str2bool(self.request.query_params.get('allocations', False))
|
||||
kwargs['part_detail'] = str2bool(params.get('part_detail', False))
|
||||
kwargs['order_detail'] = str2bool(params.get('order_detail', False))
|
||||
kwargs['allocations'] = str2bool(params.get('allocations', False))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
@ -486,6 +481,70 @@ class SOLineItemDetail(generics.RetrieveUpdateAPIView):
|
||||
serializer_class = SOLineItemSerializer
|
||||
|
||||
|
||||
class SOAllocationList(generics.ListCreateAPIView):
|
||||
"""
|
||||
API endpoint for listing SalesOrderAllocation objects
|
||||
"""
|
||||
|
||||
queryset = SalesOrderAllocation.objects.all()
|
||||
serializer_class = SalesOrderAllocationSerializer
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
|
||||
try:
|
||||
params = self.request.query_params
|
||||
|
||||
kwargs['part_detail'] = str2bool(params.get('part_detail', False))
|
||||
kwargs['item_detail'] = str2bool(params.get('item_detail', False))
|
||||
kwargs['order_detail'] = str2bool(params.get('order_detail', False))
|
||||
kwargs['location_detail'] = str2bool(params.get('location_detail', False))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
return self.serializer_class(*args, **kwargs)
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
# Filter by order
|
||||
params = self.request.query_params
|
||||
|
||||
# Filter by "part" reference
|
||||
part = params.get('part', None)
|
||||
|
||||
if part is not None:
|
||||
queryset = queryset.filter(item__part=part)
|
||||
|
||||
# Filter by "order" reference
|
||||
order = params.get('order', None)
|
||||
|
||||
if order is not None:
|
||||
queryset = queryset.filter(line__order=order)
|
||||
|
||||
# Filter by "outstanding" order status
|
||||
outstanding = params.get('outstanding', None)
|
||||
|
||||
if outstanding is not None:
|
||||
outstanding = str2bool(outstanding)
|
||||
|
||||
if outstanding:
|
||||
queryset = queryset.filter(line__order__status__in=SalesOrderStatus.OPEN)
|
||||
else:
|
||||
queryset = queryset.exclude(line__order__status__in=SalesOrderStatus.OPEN)
|
||||
|
||||
return queryset
|
||||
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
]
|
||||
|
||||
# Default filterable fields
|
||||
filter_fields = [
|
||||
'item',
|
||||
]
|
||||
|
||||
|
||||
class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
||||
"""
|
||||
API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload)
|
||||
@ -494,10 +553,6 @@ class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
||||
queryset = PurchaseOrderAttachment.objects.all()
|
||||
serializer_class = POAttachmentSerializer
|
||||
|
||||
filter_fields = [
|
||||
'order',
|
||||
]
|
||||
|
||||
|
||||
order_api_urls = [
|
||||
# API endpoints for purchase orders
|
||||
@ -512,14 +567,26 @@ order_api_urls = [
|
||||
url(r'^po-line/$', POLineItemList.as_view(), name='api-po-line-list'),
|
||||
|
||||
# API endpoints for sales ordesr
|
||||
url(r'^so/(?P<pk>\d+)/$', SODetail.as_view(), name='api-so-detail'),
|
||||
url(r'so/attachment/', include([
|
||||
url(r'^.*$', SOAttachmentList.as_view(), name='api-so-attachment-list'),
|
||||
url(r'^so/', include([
|
||||
url(r'^(?P<pk>\d+)/$', SODetail.as_view(), name='api-so-detail'),
|
||||
url(r'attachment/', include([
|
||||
url(r'^.*$', SOAttachmentList.as_view(), name='api-so-attachment-list'),
|
||||
])),
|
||||
|
||||
# List all sales orders
|
||||
url(r'^.*$', SOList.as_view(), name='api-so-list'),
|
||||
])),
|
||||
|
||||
url(r'^so/.*$', SOList.as_view(), name='api-so-list'),
|
||||
|
||||
# API endpoints for sales order line items
|
||||
url(r'^so-line/(?P<pk>\d+)/$', SOLineItemDetail.as_view(), name='api-so-line-detail'),
|
||||
url(r'^so-line/$', SOLineItemList.as_view(), name='api-so-line-list'),
|
||||
url(r'^so-line/', include([
|
||||
url(r'^(?P<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
|
||||
part: 1
|
||||
quantity: 100
|
||||
destination: 5 # Desk/Drawer_1
|
||||
|
||||
# 250 x ACME0002 (M2x4 LPHS)
|
||||
# Partially received (50)
|
||||
@ -95,3 +96,10 @@
|
||||
part: 3
|
||||
quantity: 100
|
||||
|
||||
# 1 x R_4K7_0603
|
||||
- model: order.purchaseorderlineitem
|
||||
pk: 23
|
||||
fields:
|
||||
order: 1
|
||||
part: 5
|
||||
quantity: 1
|
||||
|
@ -11,9 +11,13 @@ from django.utils.translation import ugettext_lazy as _
|
||||
from mptt.fields import TreeNodeChoiceField
|
||||
|
||||
from InvenTree.forms import HelperForm
|
||||
from InvenTree.fields import RoundingDecimalFormField
|
||||
from InvenTree.fields import InvenTreeMoneyField, RoundingDecimalFormField
|
||||
from InvenTree.fields import DatePickerFormField
|
||||
|
||||
from InvenTree.helpers import clean_decimal
|
||||
|
||||
from common.forms import MatchItemForm
|
||||
|
||||
import part.models
|
||||
|
||||
from stock.models import StockLocation
|
||||
@ -79,12 +83,17 @@ class ShipSalesOrderForm(HelperForm):
|
||||
|
||||
class ReceivePurchaseOrderForm(HelperForm):
|
||||
|
||||
location = TreeNodeChoiceField(queryset=StockLocation.objects.all(), required=True, label=_('Location'), help_text=_('Receive parts to this location'))
|
||||
location = TreeNodeChoiceField(
|
||||
queryset=StockLocation.objects.all(),
|
||||
required=True,
|
||||
label=_("Destination"),
|
||||
help_text=_("Receive parts to this location"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrder
|
||||
fields = [
|
||||
'location',
|
||||
"location",
|
||||
]
|
||||
|
||||
|
||||
@ -195,6 +204,7 @@ class EditPurchaseOrderLineItemForm(HelperForm):
|
||||
'quantity',
|
||||
'reference',
|
||||
'purchase_price',
|
||||
'destination',
|
||||
'notes',
|
||||
]
|
||||
|
||||
@ -285,3 +295,36 @@ class EditSalesOrderAllocationForm(HelperForm):
|
||||
'line',
|
||||
'item',
|
||||
'quantity']
|
||||
|
||||
|
||||
class OrderMatchItemForm(MatchItemForm):
|
||||
""" Override MatchItemForm fields """
|
||||
|
||||
def get_special_field(self, col_guess, row, file_manager):
|
||||
""" Set special fields """
|
||||
|
||||
# set quantity field
|
||||
if 'quantity' in col_guess.lower():
|
||||
return forms.CharField(
|
||||
required=False,
|
||||
widget=forms.NumberInput(attrs={
|
||||
'name': 'quantity' + str(row['index']),
|
||||
'class': 'numberinput',
|
||||
'type': 'number',
|
||||
'min': '0',
|
||||
'step': 'any',
|
||||
'value': clean_decimal(row.get('quantity', '')),
|
||||
})
|
||||
)
|
||||
# set price field
|
||||
elif 'price' in col_guess.lower():
|
||||
return InvenTreeMoneyField(
|
||||
label=_(col_guess),
|
||||
decimal_places=5,
|
||||
max_digits=19,
|
||||
required=False,
|
||||
default_amount=clean_decimal(row.get('purchase_price', '')),
|
||||
)
|
||||
|
||||
# return default
|
||||
return super().get_special_field(col_guess, row, file_manager)
|
||||
|
@ -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.utils.translation import ugettext_lazy as _
|
||||
|
||||
from common.settings import currency_code_default
|
||||
|
||||
from markdownx.models import MarkdownxField
|
||||
|
||||
from djmoney.models.fields import MoneyField
|
||||
from mptt.models import TreeForeignKey
|
||||
|
||||
from users import models as UserModels
|
||||
from part import models as PartModels
|
||||
from stock import models as stock_models
|
||||
from company.models import Company, SupplierPart
|
||||
|
||||
from InvenTree.fields import RoundingDecimalField
|
||||
from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField
|
||||
from InvenTree.helpers import decimal2string, increment, getSetting
|
||||
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus, StockHistoryCode
|
||||
from InvenTree.models import InvenTreeAttachment
|
||||
@ -663,15 +660,37 @@ class PurchaseOrderLineItem(OrderLineItem):
|
||||
|
||||
received = models.DecimalField(decimal_places=5, max_digits=15, default=0, verbose_name=_('Received'), help_text=_('Number of items received'))
|
||||
|
||||
purchase_price = MoneyField(
|
||||
purchase_price = InvenTreeModelMoneyField(
|
||||
max_digits=19,
|
||||
decimal_places=4,
|
||||
default_currency=currency_code_default(),
|
||||
null=True, blank=True,
|
||||
verbose_name=_('Purchase Price'),
|
||||
help_text=_('Unit purchase price'),
|
||||
)
|
||||
|
||||
destination = TreeForeignKey(
|
||||
'stock.StockLocation', on_delete=models.DO_NOTHING,
|
||||
verbose_name=_('Destination'),
|
||||
related_name='po_lines',
|
||||
blank=True, null=True,
|
||||
help_text=_('Where does the Purchaser want this item to be stored?')
|
||||
)
|
||||
|
||||
def get_destination(self):
|
||||
"""Show where the line item is or should be placed"""
|
||||
# NOTE: If a line item gets split when recieved, only an arbitrary
|
||||
# stock items location will be reported as the location for the
|
||||
# entire line.
|
||||
for stock in stock_models.StockItem.objects.filter(
|
||||
supplier_part=self.part, purchase_order=self.order
|
||||
):
|
||||
if stock.location:
|
||||
return stock.location
|
||||
if self.destination:
|
||||
return self.destination
|
||||
if self.part and self.part.part and self.part.part.default_location:
|
||||
return self.part.part.default_location
|
||||
|
||||
def remaining(self):
|
||||
""" Calculate the number of items remaining to be received """
|
||||
r = self.quantity - self.received
|
||||
@ -692,10 +711,9 @@ class SalesOrderLineItem(OrderLineItem):
|
||||
|
||||
part = models.ForeignKey('part.Part', on_delete=models.SET_NULL, related_name='sales_order_line_items', null=True, verbose_name=_('Part'), help_text=_('Part'), limit_choices_to={'salable': True})
|
||||
|
||||
sale_price = MoneyField(
|
||||
sale_price = InvenTreeModelMoneyField(
|
||||
max_digits=19,
|
||||
decimal_places=4,
|
||||
default_currency=currency_code_default(),
|
||||
null=True, blank=True,
|
||||
verbose_name=_('Sale Price'),
|
||||
help_text=_('Unit sale price'),
|
||||
|
@ -17,6 +17,7 @@ from InvenTree.serializers import InvenTreeAttachmentSerializerField
|
||||
|
||||
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer
|
||||
from part.serializers import PartBriefSerializer
|
||||
from stock.serializers import LocationBriefSerializer, StockItemSerializer, LocationSerializer
|
||||
|
||||
from .models import PurchaseOrder, PurchaseOrderLineItem
|
||||
from .models import PurchaseOrderAttachment, SalesOrderAttachment
|
||||
@ -41,7 +42,7 @@ class POSerializer(InvenTreeModelSerializer):
|
||||
"""
|
||||
Add extra information to the queryset
|
||||
|
||||
- Number of liens in the PurchaseOrder
|
||||
- Number of lines in the PurchaseOrder
|
||||
- Overdue status of the PurchaseOrder
|
||||
"""
|
||||
|
||||
@ -91,8 +92,10 @@ class POSerializer(InvenTreeModelSerializer):
|
||||
]
|
||||
|
||||
read_only_fields = [
|
||||
'reference',
|
||||
'status'
|
||||
'issue_date',
|
||||
'complete_date',
|
||||
'creation_date',
|
||||
]
|
||||
|
||||
|
||||
@ -108,14 +111,17 @@ class POLineItemSerializer(InvenTreeModelSerializer):
|
||||
self.fields.pop('part_detail')
|
||||
self.fields.pop('supplier_part_detail')
|
||||
|
||||
quantity = serializers.FloatField()
|
||||
received = serializers.FloatField()
|
||||
# TODO: Once https://github.com/inventree/InvenTree/issues/1687 is fixed, remove default values
|
||||
quantity = serializers.FloatField(default=1)
|
||||
received = serializers.FloatField(default=0)
|
||||
|
||||
part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True)
|
||||
supplier_part_detail = SupplierPartSerializer(source='part', many=False, read_only=True)
|
||||
|
||||
purchase_price_string = serializers.CharField(source='purchase_price', read_only=True)
|
||||
|
||||
destination = LocationBriefSerializer(source='get_destination', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrderLineItem
|
||||
|
||||
@ -132,6 +138,7 @@ class POLineItemSerializer(InvenTreeModelSerializer):
|
||||
'purchase_price',
|
||||
'purchase_price_currency',
|
||||
'purchase_price_string',
|
||||
'destination',
|
||||
]
|
||||
|
||||
|
||||
@ -221,8 +228,9 @@ class SalesOrderSerializer(InvenTreeModelSerializer):
|
||||
]
|
||||
|
||||
read_only_fields = [
|
||||
'reference',
|
||||
'status'
|
||||
'status',
|
||||
'creation_date',
|
||||
'shipment_date',
|
||||
]
|
||||
|
||||
|
||||
@ -232,11 +240,38 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
|
||||
This includes some fields from the related model objects.
|
||||
"""
|
||||
|
||||
location_path = serializers.CharField(source='get_location_path')
|
||||
location_id = serializers.IntegerField(source='get_location')
|
||||
serial = serializers.CharField(source='get_serial')
|
||||
po = serializers.CharField(source='get_po')
|
||||
quantity = serializers.FloatField()
|
||||
part = serializers.PrimaryKeyRelatedField(source='item.part', read_only=True)
|
||||
order = serializers.PrimaryKeyRelatedField(source='line.order', many=False, read_only=True)
|
||||
serial = serializers.CharField(source='get_serial', read_only=True)
|
||||
quantity = serializers.FloatField(read_only=True)
|
||||
location = serializers.PrimaryKeyRelatedField(source='item.location', many=False, read_only=True)
|
||||
|
||||
# Extra detail fields
|
||||
order_detail = SalesOrderSerializer(source='line.order', many=False, read_only=True)
|
||||
part_detail = PartBriefSerializer(source='item.part', many=False, read_only=True)
|
||||
item_detail = StockItemSerializer(source='item', many=False, read_only=True)
|
||||
location_detail = LocationSerializer(source='item.location', many=False, read_only=True)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
order_detail = kwargs.pop('order_detail', False)
|
||||
part_detail = kwargs.pop('part_detail', False)
|
||||
item_detail = kwargs.pop('item_detail', False)
|
||||
location_detail = kwargs.pop('location_detail', False)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if not order_detail:
|
||||
self.fields.pop('order_detail')
|
||||
|
||||
if not part_detail:
|
||||
self.fields.pop('part_detail')
|
||||
|
||||
if not item_detail:
|
||||
self.fields.pop('item_detail')
|
||||
|
||||
if not location_detail:
|
||||
self.fields.pop('location_detail')
|
||||
|
||||
class Meta:
|
||||
model = SalesOrderAllocation
|
||||
@ -246,10 +281,14 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
|
||||
'line',
|
||||
'serial',
|
||||
'quantity',
|
||||
'location_id',
|
||||
'location_path',
|
||||
'po',
|
||||
'location',
|
||||
'location_detail',
|
||||
'item',
|
||||
'item_detail',
|
||||
'order',
|
||||
'order_detail',
|
||||
'part',
|
||||
'part_detail',
|
||||
]
|
||||
|
||||
|
||||
@ -277,7 +316,9 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
|
||||
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
||||
allocations = SalesOrderAllocationSerializer(many=True, read_only=True)
|
||||
|
||||
quantity = serializers.FloatField()
|
||||
# TODO: Once https://github.com/inventree/InvenTree/issues/1687 is fixed, remove default values
|
||||
quantity = serializers.FloatField(default=1)
|
||||
|
||||
allocated = serializers.FloatField(source='allocated_quantity', read_only=True)
|
||||
fulfilled = serializers.FloatField(source='fulfilled_quantity', read_only=True)
|
||||
sale_price_string = serializers.CharField(source='sale_price', read_only=True)
|
||||
|
@ -75,7 +75,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<tr>
|
||||
<td><span class='fas fa-hashtag'></span></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>
|
||||
<td><span class='fas fa-info'></span></td>
|
||||
|
@ -2,6 +2,7 @@
|
||||
{% load inventree_extras %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block form_alert %}
|
||||
{% if form.errors %}
|
||||
@ -67,7 +68,7 @@
|
||||
<td>
|
||||
{% for field in form.visible_fields %}
|
||||
{% if field.name == row.quantity %}
|
||||
{{ field }}
|
||||
{{ field|as_crispy_field }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if row.errors.quantity %}
|
||||
@ -80,19 +81,19 @@
|
||||
{% if item.column.guess == 'Purchase_Price' %}
|
||||
{% for field in form.visible_fields %}
|
||||
{% if field.name == row.purchase_price %}
|
||||
{{ field }}
|
||||
{{ field|as_crispy_field }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% elif item.column.guess == 'Reference' %}
|
||||
{% for field in form.visible_fields %}
|
||||
{% if field.name == row.reference %}
|
||||
{{ field }}
|
||||
{{ field|as_crispy_field }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% elif item.column.guess == 'Notes' %}
|
||||
{% for field in form.visible_fields %}
|
||||
{% if field.name == row.notes %}
|
||||
{{ field }}
|
||||
{{ field|as_crispy_field }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
|
@ -4,6 +4,8 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% block form %}
|
||||
{% default_currency as currency %}
|
||||
{% settings_value 'PART_SHOW_PRICE_IN_FORMS' as show_price %}
|
||||
|
||||
<h4>
|
||||
{% 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 }}">
|
||||
<option value=''>---------</option>
|
||||
{% 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 %}
|
||||
</select>
|
||||
</div>
|
||||
|
@ -57,8 +57,6 @@ $("#attachment-table").on('click', '.attachment-delete-button', function() {
|
||||
|
||||
var url = `/order/purchase-order/attachment/${button.attr('pk')}/delete/`;
|
||||
|
||||
console.log("url: " + url);
|
||||
|
||||
launchModalForm(url, {
|
||||
reload: true,
|
||||
});
|
||||
|
@ -139,7 +139,7 @@ $("#po-table").inventreeTable({
|
||||
}
|
||||
},
|
||||
footerFormatter: function() {
|
||||
return 'Total'
|
||||
return '{% trans "Total" %}'
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -234,6 +234,10 @@ $("#po-table").inventreeTable({
|
||||
return (progressA < progressB) ? 1 : -1;
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'destination.pathstring',
|
||||
title: '{% trans "Destination" %}',
|
||||
},
|
||||
{
|
||||
field: 'notes',
|
||||
title: '{% trans "Notes" %}',
|
||||
|
@ -22,6 +22,7 @@
|
||||
<th>{% trans "Received" %}</th>
|
||||
<th>{% trans "Receive" %}</th>
|
||||
<th>{% trans "Status" %}</th>
|
||||
<th>{% trans "Destination" %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
{% for line in lines %}
|
||||
@ -53,6 +54,9 @@
|
||||
</select>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{{ line.get_destination }}
|
||||
</td>
|
||||
<td>
|
||||
<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>
|
||||
|
@ -77,7 +77,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<tr>
|
||||
<td><span class='fas fa-hashtag'></span></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>
|
||||
<td><span class='fas fa-info'></span></td>
|
||||
|
@ -81,10 +81,10 @@ function showAllocationSubTable(index, row, element) {
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'location_id',
|
||||
field: 'location',
|
||||
title: 'Location',
|
||||
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,
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
showFooter: true,
|
||||
columns: [
|
||||
{
|
||||
field: 'pk',
|
||||
@ -217,7 +218,10 @@ $("#so-lines-table").inventreeTable({
|
||||
} else {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
},
|
||||
footerFormatter: function() {
|
||||
return '{% trans "Total" %}'
|
||||
},
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
@ -228,6 +232,13 @@ $("#so-lines-table").inventreeTable({
|
||||
sortable: true,
|
||||
field: '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,
|
||||
@ -237,6 +248,26 @@ $("#so-lines-table").inventreeTable({
|
||||
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',
|
||||
{% if order.status == SalesOrderStatus.PENDING %}
|
||||
|
@ -110,6 +110,96 @@ class PurchaseOrderTest(OrderTest):
|
||||
|
||||
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):
|
||||
"""
|
||||
@ -158,8 +248,6 @@ class SalesOrderTest(OrderTest):
|
||||
|
||||
response = self.get(url)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
data = response.data
|
||||
|
||||
self.assertEqual(data['pk'], 1)
|
||||
@ -168,6 +256,87 @@ class SalesOrderTest(OrderTest):
|
||||
|
||||
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)
|
||||
|
||||
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')
|
||||
part = sku.part
|
||||
@ -105,11 +105,11 @@ class OrderTest(TestCase):
|
||||
order.add_line_item(sku, 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.add_line_item(sku, 50)
|
||||
self.assertEqual(order.lines.count(), 4)
|
||||
self.assertEqual(order.lines.count(), 5)
|
||||
self.assertEqual(part.on_order, 150)
|
||||
|
||||
# Try to order a supplier part from the wrong supplier
|
||||
@ -163,7 +163,7 @@ class OrderTest(TestCase):
|
||||
loc = StockLocation.objects.get(id=1)
|
||||
|
||||
# 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"
|
||||
self.assertEqual(order.status, PurchaseOrderStatus.PENDING)
|
||||
|
@ -30,7 +30,9 @@ from stock.models import StockItem, StockLocation
|
||||
from part.models import Part
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
from common.forms import UploadFileForm, MatchFieldForm
|
||||
from common.views import FileManagementFormView
|
||||
from common.files import FileManager
|
||||
|
||||
from . import forms as order_forms
|
||||
from part.views import PartPricing
|
||||
@ -572,7 +574,28 @@ class SalesOrderShip(AjaxUpdateView):
|
||||
class PurchaseOrderUpload(FileManagementFormView):
|
||||
''' 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'
|
||||
form_list = [
|
||||
('upload', UploadFileForm),
|
||||
('fields', MatchFieldForm),
|
||||
('items', order_forms.OrderMatchItemForm),
|
||||
]
|
||||
form_steps_template = [
|
||||
'order/order_wizard/po_upload.html',
|
||||
'order/order_wizard/match_fields.html',
|
||||
@ -583,7 +606,6 @@ class PurchaseOrderUpload(FileManagementFormView):
|
||||
_("Match Fields"),
|
||||
_("Match Supplier Parts"),
|
||||
]
|
||||
# Form field name: PurchaseOrderLineItem field
|
||||
form_field_map = {
|
||||
'item_select': 'part',
|
||||
'quantity': 'quantity',
|
||||
@ -591,6 +613,7 @@ class PurchaseOrderUpload(FileManagementFormView):
|
||||
'reference': 'reference',
|
||||
'notes': 'notes',
|
||||
}
|
||||
file_manager_class = OrderFileManager
|
||||
|
||||
def get_order(self):
|
||||
""" Get order or return 404 """
|
||||
@ -598,6 +621,8 @@ class PurchaseOrderUpload(FileManagementFormView):
|
||||
return get_object_or_404(PurchaseOrder, pk=self.kwargs['pk'])
|
||||
|
||||
def get_context_data(self, form, **kwargs):
|
||||
""" Handle context data for order """
|
||||
|
||||
context = super().get_context_data(form=form, **kwargs)
|
||||
|
||||
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 """
|
||||
|
||||
order = self.get_order()
|
||||
|
||||
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
|
||||
items = self.get_clean_items()
|
||||
|
||||
# Create PurchaseOrderLineItem instances
|
||||
for purchase_order_item in items.values():
|
||||
@ -1004,6 +1010,15 @@ class OrderParts(AjaxView):
|
||||
|
||||
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):
|
||||
""" 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.
|
||||
@ -1238,9 +1253,10 @@ class OrderParts(AjaxView):
|
||||
valid = False
|
||||
|
||||
if form_step == 'select_parts':
|
||||
# No errors? Proceed to PO selection form
|
||||
if part_errors is False:
|
||||
# No errors? and the price-update button was not used to submit? Proceed to PO selection form
|
||||
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.form_step = 'select_purchase_orders' # set step (important for get_data)
|
||||
|
||||
else:
|
||||
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 PartCategoryParameterTemplate
|
||||
from .models import PartTestTemplate
|
||||
from .models import PartSellPriceBreak
|
||||
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||
|
||||
from stock.models import StockLocation
|
||||
from company.models import SupplierPart
|
||||
@ -111,6 +111,13 @@ class PartCategoryResource(ModelResource):
|
||||
PartCategory.objects.rebuild()
|
||||
|
||||
|
||||
class PartCategoryInline(admin.TabularInline):
|
||||
"""
|
||||
Inline for PartCategory model
|
||||
"""
|
||||
model = PartCategory
|
||||
|
||||
|
||||
class PartCategoryAdmin(ImportExportModelAdmin):
|
||||
|
||||
resource_class = PartCategoryResource
|
||||
@ -119,6 +126,10 @@ class PartCategoryAdmin(ImportExportModelAdmin):
|
||||
|
||||
search_fields = ('name', 'description')
|
||||
|
||||
inlines = [
|
||||
PartCategoryInline,
|
||||
]
|
||||
|
||||
|
||||
class PartRelatedAdmin(admin.ModelAdmin):
|
||||
''' Class to manage PartRelated objects '''
|
||||
@ -286,6 +297,14 @@ class PartSellPriceBreakAdmin(admin.ModelAdmin):
|
||||
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(PartCategory, PartCategoryAdmin)
|
||||
admin.site.register(PartRelated, PartRelatedAdmin)
|
||||
@ -297,3 +316,4 @@ admin.site.register(PartParameter, ParameterAdmin)
|
||||
admin.site.register(PartCategoryParameterTemplate, PartCategoryParameterAdmin)
|
||||
admin.site.register(PartTestTemplate, PartTestTemplateAdmin)
|
||||
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 PartParameter, PartParameterTemplate
|
||||
from .models import PartAttachment, PartTestTemplate
|
||||
from .models import PartSellPriceBreak
|
||||
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||
from .models import PartCategoryParameterTemplate
|
||||
|
||||
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):
|
||||
"""
|
||||
API endpoint for listing (and creating) a PartAttachment (file upload).
|
||||
@ -688,6 +706,7 @@ class PartList(generics.ListCreateAPIView):
|
||||
'creation_date',
|
||||
'IPN',
|
||||
'in_stock',
|
||||
'category',
|
||||
]
|
||||
|
||||
# Default ordering
|
||||
@ -1017,6 +1036,11 @@ part_api_urls = [
|
||||
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
|
||||
url(r'^parameter/', include([
|
||||
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")
|
||||
|
||||
try:
|
||||
for part in Part.objects.all():
|
||||
# Only check parts which have images
|
||||
for part in Part.objects.exclude(image=None):
|
||||
if part.image:
|
||||
url = part.image.thumbnail.name
|
||||
loc = os.path.join(settings.MEDIA_ROOT, url)
|
||||
@ -50,8 +51,7 @@ class PartConfig(AppConfig):
|
||||
part.image.render_variations(replace=False)
|
||||
except FileNotFoundError:
|
||||
logger.warning(f"Image file '{part.image}' missing")
|
||||
part.image = None
|
||||
part.save()
|
||||
pass
|
||||
except UnidentifiedImageError:
|
||||
logger.warning(f"Image file '{part.image}' is invalid")
|
||||
except (OperationalError, ProgrammingError):
|
||||
|
@ -6,7 +6,7 @@
|
||||
name: 'M2x4 LPHS'
|
||||
description: 'M2x4 low profile head screw'
|
||||
category: 8
|
||||
link: www.acme.com/parts/m2x4lphs
|
||||
link: http://www.acme.com/parts/m2x4lphs
|
||||
tree_id: 0
|
||||
purchaseable: True
|
||||
level: 0
|
||||
@ -56,6 +56,7 @@
|
||||
fields:
|
||||
name: 'C_22N_0805'
|
||||
description: '22nF capacitor in 0805 package'
|
||||
purchaseable: true
|
||||
category: 3
|
||||
tree_id: 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 PartCategoryParameterTemplate
|
||||
from .models import PartTestTemplate
|
||||
from .models import PartSellPriceBreak
|
||||
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||
|
||||
|
||||
class PartModelChoiceField(forms.ModelChoiceField):
|
||||
@ -394,3 +394,19 @@ class EditPartSalePriceBreakForm(HelperForm):
|
||||
'quantity',
|
||||
'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,7 +71,8 @@ def migrate_currencies(apps, schema_editor):
|
||||
|
||||
count += 1
|
||||
|
||||
print(f"Updated {count} SupplierPriceBreak rows")
|
||||
if count > 0:
|
||||
print(f"Updated {count} SupplierPriceBreak rows")
|
||||
|
||||
def reverse_currencies(apps, schema_editor):
|
||||
"""
|
||||
|
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.models import InvenTreeTree, InvenTreeAttachment
|
||||
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
|
||||
|
||||
@ -321,6 +321,9 @@ class Part(MPTTModel):
|
||||
verbose_name = _("Part")
|
||||
verbose_name_plural = _("Parts")
|
||||
ordering = ['name', ]
|
||||
constraints = [
|
||||
UniqueConstraint(fields=['name', 'IPN', 'revision'], name='unique_part')
|
||||
]
|
||||
|
||||
class MPTTMeta:
|
||||
# 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}'")
|
||||
previous.image.delete(save=False)
|
||||
|
||||
self.clean()
|
||||
self.validate_unique()
|
||||
self.full_clean()
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@ -643,23 +645,6 @@ class Part(MPTTModel):
|
||||
'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):
|
||||
"""
|
||||
Perform cleaning operations for the Part model
|
||||
@ -1494,16 +1479,17 @@ class Part(MPTTModel):
|
||||
|
||||
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
|
||||
|
||||
Args:
|
||||
quantity: Number of units to calculate price for
|
||||
buy: Include supplier 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:
|
||||
return None
|
||||
@ -1544,7 +1530,7 @@ class Part(MPTTModel):
|
||||
|
||||
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.
|
||||
Adds the minimum price for all components in the BOM.
|
||||
|
||||
@ -1561,7 +1547,7 @@ class Part(MPTTModel):
|
||||
print("Warning: Item contains itself in BOM")
|
||||
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:
|
||||
continue
|
||||
@ -1585,19 +1571,25 @@ class Part(MPTTModel):
|
||||
|
||||
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:
|
||||
|
||||
- Supplier price (if purchased from suppliers)
|
||||
- BOM price (if built from other parts)
|
||||
- Internal price (if set for the part)
|
||||
|
||||
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
|
||||
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:
|
||||
return bom_price_range
|
||||
@ -1649,6 +1641,22 @@ class Part(MPTTModel):
|
||||
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
|
||||
def copy_bom_from(self, other, clear=True, **kwargs):
|
||||
"""
|
||||
@ -1983,6 +1991,21 @@ class PartSellPriceBreak(common.models.PriceBreak):
|
||||
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):
|
||||
""" 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(
|
||||
parent=self.part.full_name,
|
||||
child=self.sub_part.full_name,
|
||||
n=helpers.decimal2string(self.quantity))
|
||||
n=decimal2string(self.quantity))
|
||||
|
||||
def available_stock(self):
|
||||
"""
|
||||
@ -2475,10 +2498,12 @@ class BomItem(models.Model):
|
||||
return required
|
||||
|
||||
@property
|
||||
def price_range(self):
|
||||
def price_range(self, internal=False):
|
||||
""" 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:
|
||||
return prange
|
||||
@ -2486,11 +2511,11 @@ class BomItem(models.Model):
|
||||
pmin, pmax = prange
|
||||
|
||||
if pmin == pmax:
|
||||
return decimal2string(pmin)
|
||||
return decimal2money(pmin)
|
||||
|
||||
# Convert to better string representation
|
||||
pmin = decimal2string(pmin)
|
||||
pmax = decimal2string(pmax)
|
||||
pmin = decimal2money(pmin)
|
||||
pmax = decimal2money(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.models import Q
|
||||
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 sql_util.utils import SubqueryCount, SubquerySum
|
||||
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 .models import (BomItem, Part, PartAttachment, PartCategory,
|
||||
PartParameter, PartParameterTemplate, PartSellPriceBreak,
|
||||
PartStar, PartTestTemplate, PartCategoryParameterTemplate)
|
||||
PartStar, PartTestTemplate, PartCategoryParameterTemplate,
|
||||
PartInternalPriceBreak)
|
||||
|
||||
|
||||
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):
|
||||
"""
|
||||
Serializer for the 'image' field of the Part model.
|
||||
@ -280,7 +303,7 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
stock_item_count = 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)
|
||||
starred = serializers.SerializerMethodField()
|
||||
|
||||
@ -354,7 +377,7 @@ class PartStarSerializer(InvenTreeModelSerializer):
|
||||
class BomItemSerializer(InvenTreeModelSerializer):
|
||||
""" Serializer for BomItem object """
|
||||
|
||||
# price_range = serializers.CharField(read_only=True)
|
||||
price_range = serializers.CharField(read_only=True)
|
||||
|
||||
quantity = serializers.FloatField()
|
||||
|
||||
@ -469,7 +492,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
'reference',
|
||||
'sub_part',
|
||||
'sub_part_detail',
|
||||
# 'price_range',
|
||||
'price_range',
|
||||
'validated',
|
||||
]
|
||||
|
||||
|
@ -8,52 +8,43 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Part Stock Allocations" %}
|
||||
{% trans "Build Order Allocations" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block details %}
|
||||
<table class='table table-striped table-condensed' id='build-table'>
|
||||
<tr>
|
||||
<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>
|
||||
|
||||
<table class='table table-striped table-condensed' id='build-order-table'></table>
|
||||
|
||||
{% 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.super }}
|
||||
|
||||
$("#build-table").inventreeTable({
|
||||
columns: [
|
||||
{
|
||||
title: '{% trans "Order" %}',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
title: '{% trans "Stock Item" %}',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
title: '{% trans "Quantity" %}',
|
||||
sortable: true,
|
||||
}
|
||||
]
|
||||
loadSalesOrderAllocationTable("#sales-order-table", {
|
||||
params: {
|
||||
part: {{ part.id }},
|
||||
}
|
||||
});
|
||||
|
||||
loadBuildOrderAllocationTable("#build-order-table", {
|
||||
params: {
|
||||
part: {{ part.id }},
|
||||
}
|
||||
});
|
||||
|
||||
{% endblock %}
|
||||
|
@ -8,6 +8,15 @@
|
||||
|
||||
{% 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='row'>
|
||||
<div class='col-sm-6'>
|
||||
|
@ -1,4 +1,7 @@
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% settings_value 'PART_SHOW_IMPORT' as show_import %}
|
||||
|
||||
<ul class='list-group'>
|
||||
|
||||
@ -30,6 +33,15 @@
|
||||
</a>
|
||||
</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 %}
|
||||
<li class='list-group-item {% if tab == "parameters" %}active{% endif %}' title='{% trans "Parameters" %}'>
|
||||
<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 inventree_extras %}
|
||||
|
||||
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
||||
{% settings_value 'PART_SHOW_RELATED' as show_related %}
|
||||
|
||||
<ul class='list-group'>
|
||||
<li class='list-group-item'>
|
||||
<a href='#' id='part-menu-toggle'>
|
||||
@ -94,7 +97,13 @@
|
||||
</a>
|
||||
</li>
|
||||
{% 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" %}'>
|
||||
<a href='{% url "part-sale-prices" part.id %}'>
|
||||
<span class='menu-tab-icon fas fa-dollar-sign sidebar-icon'></span>
|
||||
@ -116,12 +125,14 @@
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if show_related %}
|
||||
<li class='list-group-item {% if tab == "related" %}active{% endif %}' title='{% trans "Related Parts" %}'>
|
||||
<a href='{% url "part-related" part.id %}'>
|
||||
<span class='menu-tab-icon fas fa-random sidebar-icon'></span>
|
||||
{% trans "Related Parts" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class='list-group-item {% if tab == "attachments" %}active{% endif %}' title='{% trans "Attachments" %}'>
|
||||
<a href='{% url "part-attachments" part.id %}'>
|
||||
<span class='menu-tab-icon fas fa-paperclip sidebar-icon'></span>
|
||||
|
@ -14,8 +14,19 @@
|
||||
|
||||
{% block details %}
|
||||
{% 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">
|
||||
<h4>{% trans "Pricing ranges" %}</h4>
|
||||
@ -77,6 +88,21 @@
|
||||
{% 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 %}
|
||||
<tr>
|
||||
<td><b>{% trans 'Sale Price' %}</b></td>
|
||||
@ -110,9 +136,9 @@
|
||||
|
||||
{% if price_history %}
|
||||
<hr>
|
||||
<h4>{% trans 'Stock Pricing' %}<i class="fas fa-info-circle" title="Shows the prices of stock for this part
|
||||
the part single price shown is the current price for that supplier part"></i></h4>
|
||||
{% if price_history|length > 1 %}
|
||||
<h4>{% trans 'Stock Pricing' %}<i class="fas fa-info-circle" title="Shows the purchase prices of stock for this part.
|
||||
The part single price is the current purchase price for that supplier part."></i></h4>
|
||||
{% if price_history|length > 0 %}
|
||||
<div style="max-width: 99%; min-height: 300px">
|
||||
<canvas id="StockPriceChart"></canvas>
|
||||
</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 %}
|
||||
],
|
||||
borderWidth: 1,
|
||||
type: 'line'
|
||||
type: 'line',
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
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 %}
|
||||
],
|
||||
borderWidth: 1,
|
||||
type: 'line'
|
||||
type: 'line',
|
||||
hidden: true,
|
||||
},
|
||||
{% endif %}
|
||||
{
|
||||
@ -187,18 +215,18 @@ the part single price shown is the current price for that supplier part"></i></h
|
||||
var bomdata = {
|
||||
labels: [{% for line in bom_parts %}'{{ line.name }}',{% endfor %}],
|
||||
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',
|
||||
data: [{% for line in bom_parts %}{{ line.max_price }},{% endfor %}],
|
||||
backgroundColor: bom_colors,
|
||||
},
|
||||
{% 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)
|
||||
|
@ -195,8 +195,13 @@
|
||||
|
||||
</div>
|
||||
|
||||
{% block pre_content_panel %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
<div class='panel panel-default panel-inventree'>
|
||||
|
||||
|
||||
<div class='panel-heading'>
|
||||
<h4>
|
||||
{% block heading %}
|
||||
@ -210,7 +215,11 @@
|
||||
<!-- Specific part details go here... -->
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% block post_content_panel %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@ -230,11 +239,19 @@
|
||||
|
||||
enableDragAndDrop(
|
||||
'#part-thumb',
|
||||
"{% url 'part-image-upload' part.id %}",
|
||||
"{% url 'api-part-detail' part.id %}",
|
||||
{
|
||||
label: 'image',
|
||||
method: 'PATCH',
|
||||
success: function(data, status, xhr) {
|
||||
location.reload();
|
||||
|
||||
// If image / thumbnail data present, live update
|
||||
if (data.image) {
|
||||
$('#part-image').attr('src', data.image);
|
||||
} else {
|
||||
// Otherwise, reload the page
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
@ -3,7 +3,10 @@
|
||||
{% load i18n inventree_extras %}
|
||||
|
||||
{% block pre_form_content %}
|
||||
|
||||
{% default_currency as currency %}
|
||||
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
||||
|
||||
<table class='table table-striped table-condensed table-price-two'>
|
||||
<tr>
|
||||
<td><b>{% trans 'Part' %}</b></td>
|
||||
@ -74,6 +77,22 @@
|
||||
</table>
|
||||
{% 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 %}
|
||||
<h4>{% trans 'Sale Price' %}</h4>
|
||||
<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 sys
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.conf import settings as djangosettings
|
||||
@ -114,6 +115,14 @@ def inventree_title(*args, **kwargs):
|
||||
return version.inventreeInstanceTitle()
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def python_version(*args, **kwargs):
|
||||
"""
|
||||
Return the current python version
|
||||
"""
|
||||
return sys.version.split(' ')[0]
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_version(*args, **kwargs):
|
||||
""" Return InvenTree version string """
|
||||
@ -208,6 +217,29 @@ def get_color_theme_css(username):
|
||||
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()
|
||||
def authorized_owners(group):
|
||||
""" Return authorized owners """
|
||||
|
@ -1,14 +1,20 @@
|
||||
from rest_framework import status
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import PIL
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from part.models import Part
|
||||
from stock.models import StockItem
|
||||
from company.models import Company
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
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):
|
||||
"""
|
||||
@ -230,6 +236,18 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
response = self.client.get(url, data={'part': 10004})
|
||||
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)
|
||||
response = self.client.post(
|
||||
url,
|
||||
@ -237,6 +255,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
'part': 10000,
|
||||
'test_name': 'New Test',
|
||||
'required': True,
|
||||
'description': 'a test description'
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
@ -248,7 +267,8 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
url,
|
||||
data={
|
||||
'part': 10004,
|
||||
'test_name': " newtest"
|
||||
'test_name': " newtest",
|
||||
'description': 'dafsdf',
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
@ -292,6 +312,297 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
|
||||
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):
|
||||
"""
|
||||
@ -319,6 +630,8 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
|
||||
# Add a new part
|
||||
self.part = Part.objects.create(
|
||||
name='Banana',
|
||||
description='This is a banana',
|
||||
category=PartCategory.objects.get(pk=1),
|
||||
)
|
||||
|
||||
# Create some stock items associated with the part
|
||||
|
@ -1,5 +1,6 @@
|
||||
from django.test import TestCase
|
||||
import django.core.exceptions as django_exceptions
|
||||
from decimal import Decimal
|
||||
|
||||
from .models import Part, BomItem
|
||||
|
||||
@ -11,11 +12,16 @@ class BomItemTest(TestCase):
|
||||
'part',
|
||||
'location',
|
||||
'bom',
|
||||
'company',
|
||||
'supplier_part',
|
||||
'part_pricebreaks',
|
||||
'price_breaks',
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
self.bob = Part.objects.get(id=100)
|
||||
self.orphan = Part.objects.get(name='Orphan')
|
||||
self.r1 = Part.objects.get(name='R_2K2_0805')
|
||||
|
||||
def test_str(self):
|
||||
b = BomItem.objects.get(id=1)
|
||||
@ -111,3 +117,10 @@ class BomItemTest(TestCase):
|
||||
item.validate_hash()
|
||||
|
||||
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):
|
||||
|
||||
t1 = PartParameterTemplate.objects.get(pk=1)
|
||||
self.assertEquals(str(t1), 'Length (mm)')
|
||||
self.assertEqual(str(t1), 'Length (mm)')
|
||||
|
||||
p1 = PartParameter.objects.get(pk=1)
|
||||
self.assertEqual(str(p1), 'M2x4 LPHS : Length = 4mm')
|
||||
|
@ -11,7 +11,7 @@ from django.core.exceptions import ValidationError
|
||||
|
||||
import os
|
||||
|
||||
from .models import Part, PartTestTemplate
|
||||
from .models import Part, PartCategory, PartTestTemplate
|
||||
from .models import rename_part_image, match_part_names
|
||||
from .templatetags import inventree_extras
|
||||
|
||||
@ -51,6 +51,7 @@ class PartTest(TestCase):
|
||||
'category',
|
||||
'part',
|
||||
'location',
|
||||
'part_pricebreaks'
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
@ -77,6 +78,61 @@ class PartTest(TestCase):
|
||||
p = Part.objects.get(pk=100)
|
||||
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):
|
||||
self.assertEqual(self.r1.name, 'R_2K2_0805')
|
||||
self.assertEqual(self.r1.get_absolute_url(), '/part/3/')
|
||||
@ -113,6 +169,22 @@ class PartTest(TestCase):
|
||||
|
||||
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):
|
||||
|
||||
@ -260,21 +332,24 @@ class PartSettingsTest(TestCase):
|
||||
"""
|
||||
|
||||
# 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)
|
||||
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)
|
||||
Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='B')
|
||||
|
||||
# And attempt again with the same values (should fail)
|
||||
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
|
||||
InvenTreeSetting.set_setting('PART_ALLOW_DUPLICATE_IPN', False, self.user)
|
||||
|
||||
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'),
|
||||
]
|
||||
|
||||
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 = [
|
||||
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'),
|
||||
@ -65,6 +71,7 @@ part_detail_urls = [
|
||||
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'^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'^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'),
|
||||
@ -121,6 +128,10 @@ part_urls = [
|
||||
# Create a new part
|
||||
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
|
||||
url(r'^bom/new/?', views.BomItemCreate.as_view(), name='bom-item-create'),
|
||||
|
||||
@ -145,6 +156,9 @@ part_urls = [
|
||||
# Part price breaks
|
||||
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
|
||||
url(r'^test-template/', include([
|
||||
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 import HiddenInput, CheckboxInput
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
|
||||
from moneyed import CURRENCIES
|
||||
from djmoney.contrib.exchange.models import convert_money
|
||||
@ -36,10 +37,14 @@ from .models import PartCategoryParameterTemplate
|
||||
from .models import BomItem
|
||||
from .models import match_part_names
|
||||
from .models import PartTestTemplate
|
||||
from .models import PartSellPriceBreak
|
||||
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
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
|
||||
|
||||
@ -719,6 +724,168 @@ class PartCreate(AjaxCreateView):
|
||||
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):
|
||||
""" View for editing the 'notes' field of a Part object.
|
||||
Presents a live markdown editor.
|
||||
@ -814,7 +981,7 @@ class PartPricingView(PartDetail):
|
||||
part = self.get_part()
|
||||
# Stock history
|
||||
if part.total_stock > 1:
|
||||
ret = []
|
||||
price_history = []
|
||||
stock = part.stock_entries(include_variants=False, in_stock=True) # .order_by('purchase_order__date')
|
||||
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')
|
||||
else:
|
||||
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_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 [True for a in bom_items if len(set(a['price'])) == 2]:
|
||||
ctx['bom_parts'] = [{
|
||||
'name': a['name'],
|
||||
'min_price': str((a['price'][0] * a['q']) / quantity),
|
||||
'max_price': str((a['price'][1] * a['q']) / quantity)} for a in bom_items]
|
||||
ctx['bom_pie_min'] = True
|
||||
else:
|
||||
ctx['bom_parts'] = [{
|
||||
'name': a['name'],
|
||||
'price': str((a['price'][0] * a['q']) / quantity)} for a in bom_items]
|
||||
if part.has_bom:
|
||||
# get internal price setting
|
||||
use_internal = InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False)
|
||||
ctx_bom_parts = []
|
||||
# iterate over all bom-items
|
||||
for item in part.bom_items.all():
|
||||
ctx_item = {'name': str(item.sub_part)}
|
||||
price, qty = item.sub_part.get_price_range(quantity, internal=use_internal), item.quantity
|
||||
|
||||
price_min, price_max = 0, 0
|
||||
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
|
||||
|
||||
@ -2105,7 +2283,8 @@ class PartPricing(AjaxView):
|
||||
# BOM pricing information
|
||||
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:
|
||||
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_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_price = part.get_price(quantity)
|
||||
if part_price is not None:
|
||||
@ -2764,7 +2949,7 @@ class PartSalePriceBreakCreate(AjaxCreateView):
|
||||
|
||||
initials['part'] = self.get_part()
|
||||
|
||||
default_currency = settings.BASE_CURRENCY
|
||||
default_currency = inventree_settings.currency_code_default()
|
||||
currency = CURRENCIES.get(default_currency, None)
|
||||
|
||||
if currency is not None:
|
||||
@ -2794,3 +2979,29 @@ class PartSalePriceBreakDelete(AjaxDeleteView):
|
||||
model = PartSellPriceBreak
|
||||
ajax_form_title = _("Delete Price Break")
|
||||
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()
|
||||
|
||||
|
||||
class LocationInline(admin.TabularInline):
|
||||
"""
|
||||
Inline for sub-locations
|
||||
"""
|
||||
model = StockLocation
|
||||
|
||||
|
||||
class LocationAdmin(ImportExportModelAdmin):
|
||||
|
||||
resource_class = LocationResource
|
||||
@ -52,6 +59,10 @@ class LocationAdmin(ImportExportModelAdmin):
|
||||
|
||||
search_fields = ('name', 'description')
|
||||
|
||||
inlines = [
|
||||
LocationInline,
|
||||
]
|
||||
|
||||
|
||||
class StockItemResource(ModelResource):
|
||||
""" Class for managing StockItem data import/export """
|
||||
|
@ -199,7 +199,8 @@ def update_history(apps, schema_editor):
|
||||
update_count += 1
|
||||
|
||||
|
||||
print(f"\n==========================\nUpdated {update_count} StockItemHistory entries")
|
||||
if update_count > 0:
|
||||
print(f"\n==========================\nUpdated {update_count} StockItemHistory entries")
|
||||
|
||||
|
||||
def reverse_update(apps, schema_editor):
|
||||
|
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