Merge branch 'currency-support'

This commit is contained in:
Oliver Walters 2020-11-13 09:23:13 +11:00
commit ffdf03ddcf
109 changed files with 4277 additions and 2442 deletions

View File

@ -1,17 +0,0 @@
# .readthedocs.yml
# Read the Docs configuration file
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
version: 2
# Build documentation in the docs/ directory with Sphinx
sphinx:
configuration: docs/conf.py
formats: all
# Optionally set the version of Python and requirements required to build your docs
python:
version: 3.5
install:
- requirements: docs/requirements.txt

View File

@ -30,11 +30,24 @@ before_install:
script: script:
- cd InvenTree && python3 manage.py makemigrations && cd .. - cd InvenTree && python3 manage.py makemigrations && cd ..
- python3 ci/check_migration_files.py - python3 ci/check_migration_files.py
# Run unit testing / code coverage tests
- invoke coverage - invoke coverage
# Run unit test for SQL database backend
- cd InvenTree && python3 manage.py test --settings=InvenTree.ci_mysql && cd .. - cd InvenTree && python3 manage.py test --settings=InvenTree.ci_mysql && cd ..
# Run unit test for PostgreSQL database backend
- cd InvenTree && python3 manage.py test --settings=InvenTree.ci_postgresql && cd .. - cd InvenTree && python3 manage.py test --settings=InvenTree.ci_postgresql && cd ..
- invoke translate - invoke translate
- invoke style - invoke style
# Create an empty database and fill it with test data
- rm inventree_default_db.sqlite3
- invoke migrate
- invoke import-fixtures
# Export database records
- invoke export-records -f data.json
# Create a new empty database and import the saved data
- rm inventree_default_db.sqlite3
- invoke migrate
- invoke import-records -f data.json
after_success: after_success:
- coveralls - coveralls

View File

@ -0,0 +1,21 @@
from djmoney.contrib.exchange.backends.base import BaseExchangeBackend
class InvenTreeManualExchangeBackend(BaseExchangeBackend):
"""
Backend for manually updating currency exchange rates
See the documentation for django-money: https://github.com/django-money/django-money
Specifically: https://github.com/django-money/django-money/tree/master/djmoney/contrib/exchange/backends
"""
name = "inventree"
url = None
def get_rates(self, **kwargs):
"""
Do not get any rates...
"""
return {}

View File

@ -12,6 +12,7 @@ from crispy_forms.layout import Layout, Field
from crispy_forms.bootstrap import PrependedText, AppendedText, PrependedAppendedText, StrictButton, Div from crispy_forms.bootstrap import PrependedText, AppendedText, PrependedAppendedText, StrictButton, Div
from django.contrib.auth.models import User from django.contrib.auth.models import User
from common.models import ColorTheme from common.models import ColorTheme
from part.models import PartCategory
class HelperForm(forms.ModelForm): class HelperForm(forms.ModelForm):
@ -200,3 +201,33 @@ class ColorThemeSelectForm(forms.ModelForm):
css_class='row', css_class='row',
), ),
) )
class SettingCategorySelectForm(forms.ModelForm):
""" Form for setting category settings """
category = forms.ModelChoiceField(queryset=PartCategory.objects.all())
class Meta:
model = PartCategory
fields = [
'category'
]
def __init__(self, *args, **kwargs):
super(SettingCategorySelectForm, self).__init__(*args, **kwargs)
self.helper = FormHelper()
# Form rendering
self.helper.form_show_labels = False
self.helper.layout = Layout(
Div(
Div(Field('category'),
css_class='col-sm-6',
style='width: 70%;'),
Div(StrictButton(_('Select Category'), css_class='btn btn-primary', type='submit'),
css_class='col-sm-6',
style='width: 30%; padding-left: 0;'),
css_class='row',
),
)

View File

@ -155,8 +155,9 @@ INSTALLED_APPS = [
'markdownify', # Markdown template rendering 'markdownify', # Markdown template rendering
'django_tex', # LaTeX output 'django_tex', # LaTeX output
'django_admin_shell', # Python shell for the admin interface 'django_admin_shell', # Python shell for the admin interface
'djmoney', # django-money integration
'djmoney.contrib.exchange', # django-money exchange rates
'error_report', # Error reporting in the admin interface 'error_report', # Error reporting in the admin interface
] ]
LOGGING = { LOGGING = {
@ -356,6 +357,17 @@ LANGUAGES = [
('pk', _('Polish')), ('pk', _('Polish')),
] ]
# Currencies available for use
CURRENCIES = CONFIG.get(
'currencies',
[
'AUD', 'CAD', 'EUR', 'GBP', 'JPY', 'NZD', 'USD',
],
)
# TODO - Allow live web-based backends in the future
EXCHANGE_BACKEND = 'InvenTree.exchange.InvenTreeManualExchangeBackend'
LOCALE_PATHS = ( LOCALE_PATHS = (
os.path.join(BASE_DIR, 'locale/'), os.path.join(BASE_DIR, 'locale/'),
) )

View File

@ -27,8 +27,7 @@ class APITests(APITestCase):
def setUp(self): def setUp(self):
# Create a user (but do not log in!) # Create a user (but do not log in!)
User = get_user_model() get_user_model().objects.create_user(self.username, 'user@email.com', self.password)
User.objects.create_user(self.username, 'user@email.com', self.password)
def basicAuth(self): def basicAuth(self):
# Use basic authentication # Use basic authentication

View File

@ -16,8 +16,7 @@ class ViewTests(TestCase):
def setUp(self): def setUp(self):
# Create a user # Create a user
User = get_user_model() get_user_model().objects.create_user(self.username, 'user@email.com', self.password)
User.objects.create_user(self.username, 'user@email.com', self.password)
self.client.login(username=self.username, password=self.password) self.client.login(username=self.username, password=self.password)

View File

@ -36,7 +36,8 @@ from django.views.generic.base import RedirectView
from rest_framework.documentation import include_docs_urls from rest_framework.documentation import include_docs_urls
from .views import IndexView, SearchView, DatabaseStatsView from .views import IndexView, SearchView, DatabaseStatsView
from .views import SettingsView, EditUserView, SetPasswordView, ColorThemeSelectView from .views import SettingsView, EditUserView, SetPasswordView
from .views import ColorThemeSelectView, SettingCategorySelectView
from .views import DynamicJsView from .views import DynamicJsView
from common.views import SettingEdit from common.views import SettingEdit
@ -74,7 +75,7 @@ settings_urls = [
url(r'^theme/?', ColorThemeSelectView.as_view(), name='settings-theme'), url(r'^theme/?', ColorThemeSelectView.as_view(), name='settings-theme'),
url(r'^global/?', SettingsView.as_view(template_name='InvenTree/settings/global.html'), name='settings-global'), url(r'^global/?', SettingsView.as_view(template_name='InvenTree/settings/global.html'), name='settings-global'),
url(r'^currency/?', SettingsView.as_view(template_name='InvenTree/settings/currency.html'), name='settings-currency'), url(r'^category/?', SettingCategorySelectView.as_view(), name='settings-category'),
url(r'^part/?', SettingsView.as_view(template_name='InvenTree/settings/part.html'), name='settings-part'), url(r'^part/?', SettingsView.as_view(template_name='InvenTree/settings/part.html'), name='settings-part'),
url(r'^stock/?', SettingsView.as_view(template_name='InvenTree/settings/stock.html'), name='settings-stock'), url(r'^stock/?', SettingsView.as_view(template_name='InvenTree/settings/stock.html'), name='settings-stock'),
url(r'^build/?', SettingsView.as_view(template_name='InvenTree/settings/build.html'), name='settings-build'), url(r'^build/?', SettingsView.as_view(template_name='InvenTree/settings/build.html'), name='settings-build'),

View File

@ -6,11 +6,22 @@ from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from moneyed import CURRENCIES
import common.models import common.models
import re import re
def validate_currency_code(code):
"""
Check that a given code is a valid currency code.
"""
if code not in CURRENCIES:
raise ValidationError(_('Not a valid currency code'))
def allowable_url_schemes(): def allowable_url_schemes():
""" Return the list of allowable URL schemes. """ Return the list of allowable URL schemes.
In addition to the default schemes allowed by Django, In addition to the default schemes allowed by Django,

View File

@ -24,7 +24,8 @@ from stock.models import StockLocation, StockItem
from common.models import InvenTreeSetting, ColorTheme from common.models import InvenTreeSetting, ColorTheme
from users.models import check_user_role, RuleSet from users.models import check_user_role, RuleSet
from .forms import DeleteForm, EditUserForm, SetPasswordForm, ColorThemeSelectForm from .forms import DeleteForm, EditUserForm, SetPasswordForm
from .forms import ColorThemeSelectForm, SettingCategorySelectForm
from .helpers import str2bool from .helpers import str2bool
from rest_framework import views from rest_framework import views
@ -750,6 +751,42 @@ class ColorThemeSelectView(FormView):
return self.form_invalid(form) return self.form_invalid(form)
class SettingCategorySelectView(FormView):
""" View for selecting categories in settings """
form_class = SettingCategorySelectForm
success_url = reverse_lazy('settings-category')
template_name = "InvenTree/settings/category.html"
def get_initial(self):
""" Set category selection """
initial = super(SettingCategorySelectView, self).get_initial()
category = self.request.GET.get('category', None)
if category:
initial['category'] = category
return initial
def post(self, request, *args, **kwargs):
""" Handle POST request (which contains category selection).
Pass the selected category to the page template
"""
form = self.get_form()
if form.is_valid():
context = self.get_context_data()
context['category'] = form.cleaned_data['category']
return super(SettingCategorySelectView, self).render_to_response(context)
return self.form_invalid(form)
class DatabaseStatsView(AjaxView): class DatabaseStatsView(AjaxView):
""" View for displaying database statistics """ """ View for displaying database statistics """

View File

@ -24,8 +24,8 @@ class BarcodeAPITest(APITestCase):
def setUp(self): def setUp(self):
# Create a user for auth # Create a user for auth
User = get_user_model() user = get_user_model()
User.objects.create_user('testuser', 'test@testing.com', 'password') user.objects.create_user('testuser', 'test@testing.com', 'password')
self.client.login(username='testuser', password='password') self.client.login(username='testuser', password='password')

View File

@ -0,0 +1,20 @@
# Generated by Django 3.0.7 on 2020-11-10 09:11
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('stock', '0054_remove_stockitem_build_order'),
('build', '0022_buildorderattachment'),
]
operations = [
migrations.AlterField(
model_name='builditem',
name='stock_item',
field=models.ForeignKey(help_text='Source stock item', limit_choices_to={'belongs_to': None, 'sales_order': None}, on_delete=django.db.models.deletion.CASCADE, related_name='allocations', to='stock.StockItem'),
),
]

View File

@ -962,7 +962,6 @@ class BuildItem(models.Model):
related_name='allocations', related_name='allocations',
help_text=_('Source stock item'), help_text=_('Source stock item'),
limit_choices_to={ limit_choices_to={
'build_order': None,
'sales_order': None, 'sales_order': None,
'belongs_to': None, 'belongs_to': None,
} }

View File

@ -267,17 +267,17 @@ class BuildTest(TestCase):
# New stock items should have been created! # New stock items should have been created!
self.assertEqual(StockItem.objects.count(), 4) self.assertEqual(StockItem.objects.count(), 4)
A = StockItem.objects.get(pk=self.stock_1_1.pk) a = StockItem.objects.get(pk=self.stock_1_1.pk)
# This stock item has been depleted! # This stock item has been depleted!
with self.assertRaises(StockItem.DoesNotExist): with self.assertRaises(StockItem.DoesNotExist):
StockItem.objects.get(pk=self.stock_1_2.pk) StockItem.objects.get(pk=self.stock_1_2.pk)
C = StockItem.objects.get(pk=self.stock_2_1.pk) c = StockItem.objects.get(pk=self.stock_2_1.pk)
# Stock should have been subtracted from the original items # Stock should have been subtracted from the original items
self.assertEqual(A.quantity, 900) self.assertEqual(a.quantity, 900)
self.assertEqual(C.quantity, 4500) self.assertEqual(c.quantity, 4500)
# And 10 new stock items created for the build output # And 10 new stock items created for the build output
outputs = StockItem.objects.filter(build=self.build) outputs = StockItem.objects.filter(build=self.build)

View File

@ -28,10 +28,10 @@ class BuildTestSimple(TestCase):
def setUp(self): def setUp(self):
# Create a user for auth # Create a user for auth
User = get_user_model() user = get_user_model()
User.objects.create_user('testuser', 'test@testing.com', 'password') user.objects.create_user('testuser', 'test@testing.com', 'password')
self.user = User.objects.get(username='testuser') self.user = user.objects.get(username='testuser')
g = Group.objects.create(name='builders') g = Group.objects.create(name='builders')
self.user.groups.add(g) self.user.groups.add(g)
@ -109,11 +109,11 @@ class TestBuildAPI(APITestCase):
def setUp(self): def setUp(self):
# Create a user for auth # Create a user for auth
User = get_user_model() user = get_user_model()
user = User.objects.create_user('testuser', 'test@testing.com', 'password') self.user = user.objects.create_user('testuser', 'test@testing.com', 'password')
g = Group.objects.create(name='builders') g = Group.objects.create(name='builders')
user.groups.add(g) self.user.groups.add(g)
for rule in g.rule_sets.all(): for rule in g.rule_sets.all():
if rule.name == 'build': if rule.name == 'build':
@ -185,11 +185,11 @@ class TestBuildViews(TestCase):
super().setUp() super().setUp()
# Create a user # Create a user
User = get_user_model() user = get_user_model()
user = User.objects.create_user('username', 'user@email.com', 'password') self.user = user.objects.create_user('username', 'user@email.com', 'password')
g = Group.objects.create(name='builders') g = Group.objects.create(name='builders')
user.groups.add(g) self.user.groups.add(g)
for rule in g.rule_sets.all(): for rule in g.rule_sets.all():
if rule.name == 'build': if rule.name == 'build':

View File

@ -5,11 +5,7 @@ from django.contrib import admin
from import_export.admin import ImportExportModelAdmin from import_export.admin import ImportExportModelAdmin
from .models import Currency, InvenTreeSetting from .models import InvenTreeSetting
class CurrencyAdmin(ImportExportModelAdmin):
list_display = ('symbol', 'suffix', 'description', 'value', 'base')
class SettingsAdmin(ImportExportModelAdmin): class SettingsAdmin(ImportExportModelAdmin):
@ -17,5 +13,4 @@ class SettingsAdmin(ImportExportModelAdmin):
list_display = ('key', 'value') list_display = ('key', 'value')
admin.site.register(Currency, CurrencyAdmin)
admin.site.register(InvenTreeSetting, SettingsAdmin) admin.site.register(InvenTreeSetting, SettingsAdmin)

View File

@ -5,35 +5,5 @@ Provides a JSON API for common components.
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from rest_framework import permissions, generics, filters
from django.conf.urls import url
from .models import Currency
from .serializers import CurrencySerializer
class CurrencyList(generics.ListCreateAPIView):
""" API endpoint for accessing a list of Currency objects.
- GET: Return a list of Currencies
- POST: Create a new currency
"""
queryset = Currency.objects.all()
serializer_class = CurrencySerializer
permission_classes = [
permissions.IsAuthenticated,
]
filter_backends = [
filters.OrderingFilter,
]
ordering_fields = ['suffix', 'value']
common_api_urls = [ common_api_urls = [
url(r'^currency/?$', CurrencyList.as_view(), name='api-currency-list'),
] ]

View File

@ -1,92 +1,10 @@
# -*- coding: utf-8 -*-
from django.apps import AppConfig from django.apps import AppConfig
from django.db.utils import OperationalError, ProgrammingError, IntegrityError
class CommonConfig(AppConfig): class CommonConfig(AppConfig):
name = 'common' name = 'common'
def ready(self): def ready(self):
""" Will be called when the Common app is first loaded """
self.add_instance_name()
self.add_default_settings()
def add_instance_name(self):
"""
Check if an InstanceName has been defined for this database.
If not, create a random one!
"""
# See note above
from .models import InvenTreeSetting
"""
Note: The "old" instance name was stored under the key 'InstanceName',
but has now been renamed to 'INVENTREE_INSTANCE'.
"""
try:
# Quick exit if a value already exists for 'inventree_instance'
if InvenTreeSetting.objects.filter(key='INVENTREE_INSTANCE').exists():
return
# Default instance name
instance_name = InvenTreeSetting.get_default_value('INVENTREE_INSTANCE')
# Use the old name if it exists
if InvenTreeSetting.objects.filter(key='InstanceName').exists():
instance = InvenTreeSetting.objects.get(key='InstanceName')
instance_name = instance.value
# Delete the legacy key
instance.delete()
# Create new value
InvenTreeSetting.objects.create(
key='INVENTREE_INSTANCE',
value=instance_name
)
except (OperationalError, ProgrammingError, IntegrityError):
# Migrations have not yet been applied - table does not exist
pass
def add_default_settings(self):
"""
Create all required settings, if they do not exist.
"""
from .models import InvenTreeSetting
for key in InvenTreeSetting.GLOBAL_SETTINGS.keys():
try:
settings = InvenTreeSetting.objects.filter(key__iexact=key)
if settings.count() == 0:
value = InvenTreeSetting.get_default_value(key)
print(f"Creating default setting for {key} -> '{value}'")
InvenTreeSetting.objects.create(
key=key,
value=value
)
return
elif settings.count() > 1:
# Prevent multiple shadow copies of the same setting!
for setting in settings[1:]:
setting.delete()
# Ensure that the key has the correct case
setting = settings[0]
if not setting.key == key:
setting.key = key
setting.save()
except (OperationalError, ProgrammingError, IntegrityError):
# Table might not yet exist
pass pass

View File

@ -1,16 +0,0 @@
# Test fixtures for Currency objects
- model: common.currency
fields:
symbol: '$'
suffix: 'AUD'
description: 'Australian Dollars'
base: True
- model: common.currency
fields:
symbol: '$'
suffix: 'USD'
description: 'US Dollars'
base: False
value: 1.4

View File

@ -0,0 +1,13 @@
# Sample settings objects
- model: common.InvenTreeSetting
pk: 1
fields:
key: INVENTREE_INSTANCE
value: "My very first InvenTree Instance"
- model: common.InvenTreeSetting
pk: 2
fields:
key: INVENTREE_COMPANY_NAME
value: "ACME Pty Ltd"

View File

@ -7,21 +7,7 @@ from __future__ import unicode_literals
from InvenTree.forms import HelperForm from InvenTree.forms import HelperForm
from .models import Currency, InvenTreeSetting from .models import InvenTreeSetting
class CurrencyEditForm(HelperForm):
""" Form for creating / editing a currency object """
class Meta:
model = Currency
fields = [
'symbol',
'suffix',
'description',
'value',
'base'
]
class SettingEditForm(HelperForm): class SettingEditForm(HelperForm):

View File

@ -0,0 +1,18 @@
# Generated by Django 3.0.7 on 2020-11-10 11:40
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('company', '0027_remove_supplierpricebreak_currency'),
('part', '0057_remove_partsellpricebreak_currency'),
('common', '0008_remove_inventreesetting_description'),
]
operations = [
migrations.DeleteModel(
name='Currency',
),
]

View File

@ -7,13 +7,18 @@ These models are 'generic' and do not fit a particular business logic object.
from __future__ import unicode_literals from __future__ import unicode_literals
import os import os
import decimal
from django.db import models from django.db import models
from django.conf import settings from django.conf import settings
import djmoney.settings
from djmoney.models.fields import MoneyField
from djmoney.contrib.exchange.models import convert_money
from djmoney.contrib.exchange.exceptions import MissingRate
from django.db.utils import OperationalError
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.core.validators import MinValueValidator, MaxValueValidator from django.core.validators import MinValueValidator
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
import InvenTree.helpers import InvenTree.helpers
@ -59,6 +64,13 @@ class InvenTreeSetting(models.Model):
'default': 'My company name', 'default': 'My company name',
}, },
'INVENTREE_DEFAULT_CURRENCY': {
'name': _('Default Currency'),
'description': _('Default currency'),
'default': 'USD',
'choices': djmoney.settings.CURRENCY_CHOICES,
},
'PART_IPN_REGEX': { 'PART_IPN_REGEX': {
'name': _('IPN Regex'), 'name': _('IPN Regex'),
'description': _('Regular expression pattern for matching Part IPN') 'description': _('Regular expression pattern for matching Part IPN')
@ -92,6 +104,13 @@ class InvenTreeSetting(models.Model):
'validator': bool 'validator': bool
}, },
'PART_CATEGORY_PARAMETERS': {
'name': _('Copy Category Parameter Templates'),
'description': _('Copy category parameter templates when creating a part'),
'default': True,
'validator': bool
},
'PART_COMPONENT': { 'PART_COMPONENT': {
'name': _('Component'), 'name': _('Component'),
'description': _('Parts can be used as sub-components by default'), 'description': _('Parts can be used as sub-components by default'),
@ -226,6 +245,29 @@ class InvenTreeSetting(models.Model):
else: else:
return '' return ''
@classmethod
def get_setting_choices(cls, key):
"""
Return the validator choices available for a particular setting.
"""
key = str(key).strip().upper()
if key in cls.GLOBAL_SETTINGS:
setting = cls.GLOBAL_SETTINGS[key]
choices = setting.get('choices', None)
else:
choices = None
"""
TODO:
if type(choices) is function:
# Evaluate the function (we expect it will return a list of tuples...)
return choices()
"""
return choices
@classmethod @classmethod
def get_setting_object(cls, key): def get_setting_object(cls, key):
""" """
@ -239,12 +281,20 @@ class InvenTreeSetting(models.Model):
try: try:
setting = InvenTreeSetting.objects.filter(key__iexact=key).first() setting = InvenTreeSetting.objects.filter(key__iexact=key).first()
except (InvenTreeSetting.DoesNotExist): except OperationalError:
# Create the setting if it does not exist # Settings table has not been created yet!
return None
except (ValueError, InvenTreeSetting.DoesNotExist):
try:
# Attempt Create the setting if it does not exist
setting = InvenTreeSetting.create( setting = InvenTreeSetting.create(
key=key, key=key,
value=InvenTreeSetting.get_default_value(key) value=InvenTreeSetting.get_default_value(key)
) )
except OperationalError:
# Settings table has not been created yet
setting = None
return setting return setting
@ -396,6 +446,13 @@ class InvenTreeSetting(models.Model):
except InvenTreeSetting.DoesNotExist: except InvenTreeSetting.DoesNotExist:
pass pass
def choices(self):
"""
Return the available choices for this setting (or None if no choices are defined)
"""
return InvenTreeSetting.get_setting_choices(self.key)
def is_bool(self): def is_bool(self):
""" """
Check if this setting is required to be a boolean value Check if this setting is required to be a boolean value
@ -415,74 +472,6 @@ class InvenTreeSetting(models.Model):
return InvenTree.helpers.str2bool(self.value) return InvenTree.helpers.str2bool(self.value)
class Currency(models.Model):
"""
A Currency object represents a particular unit of currency.
Each Currency has a scaling factor which relates it to the base currency.
There must be one (and only one) currency which is selected as the base currency,
and each other currency is calculated relative to it.
Attributes:
symbol: Currency symbol e.g. $
suffix: Currency suffix e.g. AUD
description: Long-form description e.g. "Australian Dollars"
value: The value of this currency compared to the base currency.
base: True if this currency is the base currency
"""
symbol = models.CharField(max_length=10, blank=False, unique=False, help_text=_('Currency Symbol e.g. $'))
suffix = models.CharField(max_length=10, blank=False, unique=True, help_text=_('Currency Suffix e.g. AUD'))
description = models.CharField(max_length=100, blank=False, help_text=_('Currency Description'))
value = models.DecimalField(default=1.0, max_digits=10, decimal_places=5, validators=[MinValueValidator(0.00001), MaxValueValidator(100000)], help_text=_('Currency Value'))
base = models.BooleanField(default=False, help_text=_('Use this currency as the base currency'))
class Meta:
verbose_name_plural = 'Currencies'
def __str__(self):
""" Format string for currency representation """
s = "{sym} {suf} - {desc}".format(
sym=self.symbol,
suf=self.suffix,
desc=self.description
)
if self.base:
s += " (Base)"
else:
s += " = {v}".format(v=self.value)
return s
def save(self, *args, **kwargs):
""" Validate the model before saving
- Ensure that there is only one base currency!
"""
# If this currency is set as the base currency, ensure no others are
if self.base:
for cur in Currency.objects.filter(base=True).exclude(pk=self.pk):
cur.base = False
cur.save()
# If there are no currencies set as the base currency, set this as base
if not Currency.objects.exclude(pk=self.pk).filter(base=True).exists():
self.base = True
# If this is the base currency, ensure value is set to unity
if self.base:
self.value = 1.0
super().save(*args, **kwargs)
class PriceBreak(models.Model): class PriceBreak(models.Model):
""" """
Represents a PriceBreak model Represents a PriceBreak model
@ -491,32 +480,39 @@ class PriceBreak(models.Model):
class Meta: class Meta:
abstract = True abstract = True
quantity = InvenTree.fields.RoundingDecimalField(max_digits=15, decimal_places=5, default=1, validators=[MinValueValidator(1)]) quantity = InvenTree.fields.RoundingDecimalField(
max_digits=15,
decimal_places=5,
default=1,
validators=[MinValueValidator(1)],
verbose_name=_('Quantity'),
help_text=_('Price break quantity'),
)
cost = InvenTree.fields.RoundingDecimalField(max_digits=10, decimal_places=5, validators=[MinValueValidator(0)]) price = MoneyField(
max_digits=19,
decimal_places=4,
default_currency='USD',
null=True,
verbose_name=_('Price'),
help_text=_('Unit price at specified quantity'),
)
currency = models.ForeignKey(Currency, blank=True, null=True, on_delete=models.SET_NULL) def convert_to(self, currency_code):
@property
def symbol(self):
return self.currency.symbol if self.currency else ''
@property
def suffix(self):
return self.currency.suffix if self.currency else ''
@property
def converted_cost(self):
""" """
Return the cost of this price break, converted to the base currency Convert the unit-price at this price break to the specified currency code.
Args:
currency_code - The currency code to convert to (e.g "USD" or "AUD")
""" """
scaler = decimal.Decimal(1.0) try:
converted = convert_money(self.price, currency_code)
except MissingRate:
print(f"WARNING: No currency conversion rate available for {self.price_currency} -> {currency_code}")
return self.price.amount
if self.currency: return converted.amount
scaler = self.currency.value
return self.cost * scaler
class ColorTheme(models.Model): class ColorTheme(models.Model):

View File

@ -1,22 +1,3 @@
""" """
JSON serializers for common components JSON serializers for common components
""" """
from .models import Currency
from InvenTree.serializers import InvenTreeModelSerializer
class CurrencySerializer(InvenTreeModelSerializer):
""" Serializer for Currency object """
class Meta:
model = Currency
fields = [
'pk',
'symbol',
'suffix',
'description',
'value',
'base'
]

View File

@ -0,0 +1,23 @@
"""
User-configurable settings for the common app
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from moneyed import CURRENCIES
from common.models import InvenTreeSetting
def currency_code_default():
"""
Returns the default currency code (or USD if not specified)
"""
code = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY')
if code not in CURRENCIES:
code = 'USD'
return code

View File

@ -4,20 +4,7 @@ from __future__ import unicode_literals
from django.test import TestCase from django.test import TestCase
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from .models import Currency, InvenTreeSetting from .models import InvenTreeSetting
class CurrencyTest(TestCase):
""" Tests for Currency model """
fixtures = [
'currency',
]
def test_currency(self):
# Simple test for now (improve this later!)
self.assertEqual(Currency.objects.count(), 2)
class SettingsTest(TestCase): class SettingsTest(TestCase):
@ -25,16 +12,34 @@ class SettingsTest(TestCase):
Tests for the 'settings' model Tests for the 'settings' model
""" """
fixtures = [
'settings',
]
def setUp(self): def setUp(self):
User = get_user_model() user = get_user_model()
self.user = User.objects.create_user('username', 'user@email.com', 'password') self.user = user.objects.create_user('username', 'user@email.com', 'password')
self.user.is_staff = True self.user.is_staff = True
self.user.save() self.user.save()
self.client.login(username='username', password='password') self.client.login(username='username', password='password')
def test_settings_objects(self):
# There should be two settings objects in the database
settings = InvenTreeSetting.objects.all()
self.assertEqual(settings.count(), 2)
instance_name = InvenTreeSetting.objects.get(pk=1)
self.assertEqual(instance_name.key, 'INVENTREE_INSTANCE')
self.assertEqual(instance_name.value, 'My very first InvenTree Instance')
# Check object lookup (case insensitive)
self.assertEqual(InvenTreeSetting.get_setting_object('iNvEnTrEE_inSTanCE').pk, 1)
def test_required_values(self): def test_required_values(self):
""" """
- Ensure that every global setting has a name. - Ensure that every global setting has a name.

View File

@ -2,17 +2,5 @@
URL lookup for common views URL lookup for common views
""" """
from django.conf.urls import url, include
from . import views
currency_urls = [
url(r'^new/', views.CurrencyCreate.as_view(), name='currency-create'),
url(r'^(?P<pk>\d+)/edit/', views.CurrencyEdit.as_view(), name='currency-edit'),
url(r'^(?P<pk>\d+)/delete/', views.CurrencyDelete.as_view(), name='currency-delete'),
]
common_urls = [ common_urls = [
url(r'currency/', include(currency_urls)),
] ]

View File

@ -6,39 +6,15 @@ Django views for interacting with common models
from __future__ import unicode_literals from __future__ import unicode_literals
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.forms import CheckboxInput from django.forms import CheckboxInput, Select
from InvenTree.views import AjaxCreateView, AjaxUpdateView, AjaxDeleteView from InvenTree.views import AjaxUpdateView
from InvenTree.helpers import str2bool from InvenTree.helpers import str2bool
from . import models from . import models
from . import forms from . import forms
class CurrencyCreate(AjaxCreateView):
""" View for creating a new Currency object """
model = models.Currency
form_class = forms.CurrencyEditForm
ajax_form_title = _('Create new Currency')
class CurrencyEdit(AjaxUpdateView):
""" View for editing an existing Currency object """
model = models.Currency
form_class = forms.CurrencyEditForm
ajax_form_title = _('Edit Currency')
class CurrencyDelete(AjaxDeleteView):
""" View for deleting an existing Currency object """
model = models.Currency
ajax_form_title = _('Delete Currency')
ajax_template_name = "common/delete_currency.html"
class SettingEdit(AjaxUpdateView): class SettingEdit(AjaxUpdateView):
""" """
View for editing an InvenTree key:value settings object, View for editing an InvenTree key:value settings object,
@ -75,7 +51,11 @@ class SettingEdit(AjaxUpdateView):
setting = self.get_object() setting = self.get_object()
if setting.is_bool(): choices = setting.choices()
if choices is not None:
form.fields['value'].widget = Select(choices=choices)
elif setting.is_bool():
form.fields['value'].widget = CheckboxInput() form.fields['value'].widget = CheckboxInput()
self.object.value = str2bool(setting.value) self.object.value = str2bool(setting.value)

View File

@ -13,7 +13,6 @@ from .models import SupplierPart
from .models import SupplierPriceBreak from .models import SupplierPriceBreak
from part.models import Part from part.models import Part
from common.models import Currency
class CompanyResource(ModelResource): class CompanyResource(ModelResource):
@ -75,8 +74,6 @@ class SupplierPriceBreakResource(ModelResource):
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(SupplierPart)) part = Field(attribute='part', widget=widgets.ForeignKeyWidget(SupplierPart))
currency = Field(attribute='currency', widget=widgets.ForeignKeyWidget(Currency))
supplier_id = Field(attribute='part__supplier__pk', readonly=True) supplier_id = Field(attribute='part__supplier__pk', readonly=True)
supplier_name = Field(attribute='part__supplier__name', readonly=True) supplier_name = Field(attribute='part__supplier__name', readonly=True)
@ -98,7 +95,7 @@ class SupplierPriceBreakAdmin(ImportExportModelAdmin):
resource_class = SupplierPriceBreakResource resource_class = SupplierPriceBreakResource
list_display = ('part', 'quantity', 'cost') list_display = ('part', 'quantity', 'price')
admin.site.register(Company, CompanyAdmin) admin.site.register(Company, CompanyAdmin)

View File

@ -7,21 +7,21 @@
fields: fields:
part: 1 part: 1
quantity: 1 quantity: 1
cost: 10 price: 10
- model: company.supplierpricebreak - model: company.supplierpricebreak
pk: 2 pk: 2
fields: fields:
part: 1 part: 1
quantity: 5 quantity: 5
cost: 7.50 price: 7.50
- model: company.supplierpricebreak - model: company.supplierpricebreak
pk: 3 pk: 3
fields: fields:
part: 1 part: 1
quantity: 25 quantity: 25
cost: 3.50 price: 3.50
# Price breaks for ACME0002 # Price breaks for ACME0002
- model: company.supplierpricebreak - model: company.supplierpricebreak
@ -29,14 +29,14 @@
fields: fields:
part: 2 part: 2
quantity: 5 quantity: 5
cost: 7.00 price: 7.00
- model: company.supplierpricebreak - model: company.supplierpricebreak
pk: 5 pk: 5
fields: fields:
part: 2 part: 2
quantity: 50 quantity: 50
cost: 1.25 price: 1.25
# Price breaks for ZERGLPHS # Price breaks for ZERGLPHS
- model: company.supplierpricebreak - model: company.supplierpricebreak
@ -44,11 +44,11 @@
fields: fields:
part: 4 part: 4
quantity: 25 quantity: 25
cost: 8 price: 8
- model: company.supplierpricebreak - model: company.supplierpricebreak
pk: 7 pk: 7
fields: fields:
part: 4 part: 4
quantity: 100 quantity: 100
cost: 1.25 price: 1.25

View File

@ -8,6 +8,14 @@ from __future__ import unicode_literals
from InvenTree.forms import HelperForm from InvenTree.forms import HelperForm
from InvenTree.fields import RoundingDecimalFormField from InvenTree.fields import RoundingDecimalFormField
from django.utils.translation import ugettext as _
import django.forms
import djmoney.settings
from djmoney.forms.fields import MoneyField
import common.settings
from .models import Company from .models import Company
from .models import SupplierPart from .models import SupplierPart
from .models import SupplierPriceBreak from .models import SupplierPriceBreak
@ -24,6 +32,13 @@ class EditCompanyForm(HelperForm):
'phone': 'fa-phone', 'phone': 'fa-phone',
} }
currency = django.forms.ChoiceField(
required=False,
help_text=_('Default currency used for this company'),
choices=[('', '----------')] + djmoney.settings.CURRENCY_CHOICES,
initial=common.settings.currency_code_default,
)
class Meta: class Meta:
model = Company model = Company
fields = [ fields = [
@ -31,6 +46,7 @@ class EditCompanyForm(HelperForm):
'description', 'description',
'website', 'website',
'address', 'address',
'currency',
'phone', 'phone',
'email', 'email',
'contact', 'contact',
@ -60,6 +76,15 @@ class EditSupplierPartForm(HelperForm):
'note': 'fa-pencil-alt', 'note': 'fa-pencil-alt',
} }
single_pricing = MoneyField(
label=_('Single Price'),
default_currency='USD',
help_text=_('Single quantity price'),
decimal_places=4,
max_digits=19,
required=False,
)
class Meta: class Meta:
model = SupplierPart model = SupplierPart
fields = [ fields = [
@ -71,8 +96,9 @@ class EditSupplierPartForm(HelperForm):
'MPN', 'MPN',
'link', 'link',
'note', 'note',
'base_cost', 'single_pricing',
'multiple', # 'base_cost',
# 'multiple',
'packaging', 'packaging',
] ]
@ -80,15 +106,17 @@ class EditSupplierPartForm(HelperForm):
class EditPriceBreakForm(HelperForm): class EditPriceBreakForm(HelperForm):
""" Form for creating / editing a supplier price break """ """ Form for creating / editing a supplier price break """
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5) quantity = RoundingDecimalFormField(
max_digits=10,
cost = RoundingDecimalFormField(max_digits=10, decimal_places=5) decimal_places=5,
label=_('Quantity'),
help_text=_('Price break quantity'),
)
class Meta: class Meta:
model = SupplierPriceBreak model = SupplierPriceBreak
fields = [ fields = [
'part', 'part',
'quantity', 'quantity',
'cost', 'price',
'currency',
] ]

View File

@ -0,0 +1,24 @@
# Generated by Django 3.0.7 on 2020-11-10 10:01
from django.db import migrations, connection
import djmoney.models.fields
class Migration(migrations.Migration):
dependencies = [
('company', '0024_unique_name_email_constraint'),
]
operations = [
migrations.AddField(
model_name='supplierpricebreak',
name='price',
field=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'),
),
migrations.AddField(
model_name='supplierpricebreak',
name='price_currency',
field=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),
),
]

View File

@ -0,0 +1,147 @@
# Generated by Django 3.0.7 on 2020-11-10 10:11
import sys
from moneyed import CURRENCIES
from django.db import migrations, connection
from company.models import SupplierPriceBreak
def migrate_currencies(apps, schema_editor):
"""
Migrate from the 'old' method of handling currencies,
to the new method which uses the django-money library.
Previously, we created a custom Currency model,
which was very simplistic.
Here we will attempt to map each existing "currency" reference
for the SupplierPriceBreak model, to a new django-money compatible currency.
"""
print("Updating currency references for SupplierPriceBreak model...")
# A list of available currency codes
currency_codes = CURRENCIES.keys()
cursor = connection.cursor()
# The 'suffix' field denotes the currency code
response = cursor.execute('SELECT id, suffix, description from common_currency;')
results = cursor.fetchall()
remap = {}
for index, row in enumerate(results):
pk, suffix, description = row
suffix = suffix.strip().upper()
if suffix not in currency_codes:
print("Missing suffix:", suffix)
while suffix not in currency_codes:
# Ask the user to input a valid currency
print(f"Could not find a valid currency matching '{suffix}'.")
print("Please enter a valid currency code")
suffix = str(input("> ")).strip()
if pk not in remap.keys():
remap[pk] = suffix
# Now iterate through each SupplierPriceBreak and update the rows
response = cursor.execute('SELECT id, cost, currency_id, price, price_currency from part_supplierpricebreak;')
results = cursor.fetchall()
count = 0
for index, row in enumerate(results):
pk, cost, currency_id, price, price_currency = row
# Copy the 'cost' field across to the 'price' field
response = cursor.execute(f'UPDATE part_supplierpricebreak set price={cost} where id={pk};')
# Extract the updated currency code
currency_code = remap.get(currency_id, 'USD')
# Update the currency code
response = cursor.execute(f'UPDATE part_supplierpricebreak set price_currency= "{currency_code}" where id={pk};')
count += 1
print(f"Updated {count} SupplierPriceBreak rows")
def reverse_currencies(apps, schema_editor):
"""
Reverse the "update" process.
Here we may be in the situation that the legacy "Currency" table is empty,
and so we have to re-populate it based on the new price_currency codes.
"""
print("Reversing currency migration...")
cursor = connection.cursor()
# Extract a list of currency codes which are in use
response = cursor.execute(f'SELECT id, price, price_currency from part_supplierpricebreak;')
results = cursor.fetchall()
codes_in_use = set()
for index, row in enumerate(results):
pk, price, code = row
codes_in_use.add(code)
# Copy the 'price' field back into the 'cost' field
response = cursor.execute(f'UPDATE part_supplierpricebreak set cost={price} where id={pk};')
# Keep a dict of which currency objects map to which code
code_map = {}
# For each currency code in use, check if we have a matching Currency object
for code in codes_in_use:
response = cursor.execute(f'SELECT id, suffix from common_currency where suffix="{code}";')
row = response.fetchone()
if row is not None:
# A match exists!
pk, suffix = row
code_map[suffix] = pk
else:
# No currency object exists!
description = CURRENCIES[code]
# Create a new object in the database
print(f"Creating new Currency object for {code}")
# Construct a query to create a new Currency object
query = f'INSERT into common_currency (symbol, suffix, description, value, base) VALUES ("$", "{code}", "{description}", 1.0, False);'
response = cursor.execute(query)
code_map[code] = cursor.lastrowid
# Ok, now we know how each suffix maps to a Currency object
for suffix in code_map.keys():
pk = code_map[suffix]
# Update the table to point to the Currency objects
print(f"Currency {suffix} -> pk {pk}")
response = cursor.execute(f'UPDATE part_supplierpricebreak set currency_id={pk} where price_currency="{suffix}";')
class Migration(migrations.Migration):
dependencies = [
('company', '0025_auto_20201110_1001'),
]
operations = [
migrations.RunPython(migrate_currencies, reverse_code=reverse_currencies),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 3.0.7 on 2020-11-10 11:40
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('company', '0026_auto_20201110_1011'),
]
operations = [
migrations.RemoveField(
model_name='supplierpricebreak',
name='currency',
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 3.0.7 on 2020-11-10 11:42
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('company', '0027_remove_supplierpricebreak_currency'),
]
operations = [
migrations.RemoveField(
model_name='supplierpricebreak',
name='cost',
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 3.0.7 on 2020-11-11 23:22
import InvenTree.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('company', '0028_remove_supplierpricebreak_cost'),
]
operations = [
migrations.AddField(
model_name='company',
name='currency',
field=models.CharField(blank=True, help_text='Default currency used for this company', max_length=3, validators=[InvenTree.validators.validate_currency_code], verbose_name='Currency'),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 3.0.7 on 2020-11-12 00:12
import InvenTree.fields
import django.core.validators
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('company', '0029_company_currency'),
]
operations = [
migrations.AlterField(
model_name='supplierpricebreak',
name='quantity',
field=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'),
),
]

View File

@ -17,6 +17,8 @@ from django.db.models import Sum, Q, UniqueConstraint
from django.apps import apps from django.apps import apps
from django.urls import reverse from django.urls import reverse
from moneyed import CURRENCIES
from markdownx.models import MarkdownxField from markdownx.models import MarkdownxField
from stdimage.models import StdImageField from stdimage.models import StdImageField
@ -26,7 +28,10 @@ from InvenTree.helpers import normalize
from InvenTree.fields import InvenTreeURLField from InvenTree.fields import InvenTreeURLField
from InvenTree.status_codes import PurchaseOrderStatus from InvenTree.status_codes import PurchaseOrderStatus
import InvenTree.validators
import common.models import common.models
import common.settings
def rename_company_image(instance, filename): def rename_company_image(instance, filename):
@ -77,6 +82,7 @@ class Company(models.Model):
is_customer: boolean value, is this company a customer is_customer: boolean value, is this company a customer
is_supplier: boolean value, is this company a supplier is_supplier: boolean value, is this company a supplier
is_manufacturer: boolean value, is this company a manufacturer is_manufacturer: boolean value, is this company a manufacturer
currency_code: Specifies the default currency for the company
""" """
class Meta: class Meta:
@ -126,6 +132,30 @@ class Company(models.Model):
is_manufacturer = models.BooleanField(default=False, help_text=_('Does this company manufacture parts?')) is_manufacturer = models.BooleanField(default=False, help_text=_('Does this company manufacture parts?'))
currency = models.CharField(
max_length=3,
verbose_name=_('Currency'),
blank=True,
help_text=_('Default currency used for this company'),
validators=[InvenTree.validators.validate_currency_code],
)
@property
def currency_code(self):
"""
Return the currency code associated with this company.
- If the currency code is invalid, use the default currency
- If the currency code is not specified, use the default currency
"""
code = self.currency
if code not in CURRENCIES:
code = common.settings.currency_code_default()
return code
def __str__(self): def __str__(self):
""" Get string representation of a Company """ """ Get string representation of a Company """
return "{n} - {d}".format(n=self.name, d=self.description) return "{n} - {d}".format(n=self.name, d=self.description)
@ -350,7 +380,26 @@ class SupplierPart(models.Model):
def unit_pricing(self): def unit_pricing(self):
return self.get_price(1) return self.get_price(1)
def get_price(self, quantity, moq=True, multiples=True): def add_price_break(self, quantity, price):
"""
Create a new price break for this part
args:
quantity - Numerical quantity
price - Must be a Money object
"""
# Check if a price break at that quantity already exists...
if self.price_breaks.filter(quantity=quantity, part=self.pk).exists():
return
SupplierPriceBreak.objects.create(
part=self,
quantity=quantity,
price=price
)
def get_price(self, quantity, moq=True, multiples=True, currency=None):
""" Calculate the supplier price based on quantity price breaks. """ Calculate the supplier price based on quantity price breaks.
- Don't forget to add in flat-fee cost (base_cost field) - Don't forget to add in flat-fee cost (base_cost field)
@ -372,6 +421,10 @@ class SupplierPart(models.Model):
pb_quantity = -1 pb_quantity = -1
pb_cost = 0.0 pb_cost = 0.0
if currency is None:
# Default currency selection
currency = common.models.InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY')
for pb in self.price_breaks.all(): for pb in self.price_breaks.all():
# Ignore this pricebreak (quantity is too high) # Ignore this pricebreak (quantity is too high)
if pb.quantity > quantity: if pb.quantity > quantity:
@ -382,8 +435,9 @@ class SupplierPart(models.Model):
# If this price-break quantity is the largest so far, use it! # If this price-break quantity is the largest so far, use it!
if pb.quantity > pb_quantity: if pb.quantity > pb_quantity:
pb_quantity = pb.quantity pb_quantity = pb.quantity
# Convert everything to base currency
pb_cost = pb.converted_cost # Convert everything to the selected currency
pb_cost = pb.convert_to(currency)
if pb_found: if pb_found:
cost = pb_cost * quantity cost = pb_cost * quantity
@ -462,7 +516,4 @@ class SupplierPriceBreak(common.models.PriceBreak):
db_table = 'part_supplierpricebreak' db_table = 'part_supplierpricebreak'
def __str__(self): def __str__(self):
return "{mpn} - {cost} @ {quan}".format( return f'{self.part.MPN} - {self.price} @ {self.quantity}'
mpn=self.part.MPN,
cost=self.cost,
quan=self.quantity)

View File

@ -137,13 +137,9 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
class SupplierPriceBreakSerializer(InvenTreeModelSerializer): class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
""" Serializer for SupplierPriceBreak object """ """ Serializer for SupplierPriceBreak object """
symbol = serializers.CharField(read_only=True)
suffix = serializers.CharField(read_only=True)
quantity = serializers.FloatField() quantity = serializers.FloatField()
cost = serializers.FloatField() price = serializers.CharField()
class Meta: class Meta:
model = SupplierPriceBreak model = SupplierPriceBreak
@ -151,8 +147,5 @@ class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
'pk', 'pk',
'part', 'part',
'quantity', 'quantity',
'cost', 'price',
'currency',
'symbol',
'suffix',
] ]

View File

@ -8,7 +8,43 @@
<h4>{% trans "Company Details" %}</h4> <h4>{% trans "Company Details" %}</h4>
<hr> <hr>
<table class='table table-striped'> <div class='row'>
<div class='col-sm-6'>
<table class='table table-striped'>
<col width='25'>
<col>
<tr>
<td><span class='fas fa-font'></span></td>
<td>{% trans "Company Name" %}</td>
<td>{{ company.name }}</td>
</tr>
<tr>
<td><span class='fas fa-info'></span></td>
<td>{% trans "Description" %}</td>
<td>{{ company.description }}</td>
</tr>
<tr>
<td><span class='fas fa-globe'></span></td>
<td>{% trans "Website" %}</td>
<td>
{% if company.website %}<a href='{{ company.website }}'>{{ company.website }}</a>
{% else %}<i>{% trans "No website specified" %}</i>
{% endif %}
</td>
</tr>
<tr>
<td><span class='fas fa-dollar-sign'></span></td>
<td>{% trans "Currency" %}</td>
<td>
{% if company.currency %}{{ company.currency }}
{% else %}<i>{% trans "Uses default currency" %}</i>
{% endif %}
</td>
</tr>
</table>
</div>
<div class='col-sm-6'>
<table class='table table-striped'>
<col width='25'> <col width='25'>
<col> <col>
<tr> <tr>
@ -26,7 +62,10 @@
<td>{% trans "Customer" %}</td> <td>{% trans "Customer" %}</td>
<td>{% include 'yesnolabel.html' with value=company.is_customer %}</td> <td>{% include 'yesnolabel.html' with value=company.is_customer %}</td>
</tr> </tr>
</table> </table>
</div>
</div>
{% endblock %} {% endblock %}
{% block js_ready %} {% block js_ready %}

View File

@ -76,18 +76,11 @@ $('#price-break-table').inventreeTable({
sortable: true, sortable: true,
}, },
{ {
field: 'cost', field: 'price',
title: '{% trans "Price" %}', title: '{% trans "Price" %}',
sortable: true, sortable: true,
formatter: function(value, row, index) { formatter: function(value, row, index) {
var html = ''; var html = value;
html += row.symbol || '';
html += value;
if (row.suffix) {
html += ' ' + row.suffix || '';
}
html += `<div class='btn-group float-right' role='group'>` html += `<div class='btn-group float-right' role='group'>`

View File

@ -15,8 +15,8 @@ class CompanyTest(APITestCase):
def setUp(self): def setUp(self):
# Create a user for auth # Create a user for auth
User = get_user_model() user = get_user_model()
self.user = User.objects.create_user('testuser', 'test@testing.com', 'password') self.user = user.objects.create_user('testuser', 'test@testing.com', 'password')
perms = [ perms = [
'view_company', 'view_company',

View File

@ -3,6 +3,8 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
import json
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
@ -11,7 +13,7 @@ from django.contrib.auth.models import Group
from .models import SupplierPart from .models import SupplierPart
class CompanyViewTest(TestCase): class CompanyViewTestBase(TestCase):
fixtures = [ fixtures = [
'category', 'category',
@ -25,8 +27,9 @@ class CompanyViewTest(TestCase):
super().setUp() super().setUp()
# Create a user # Create a user
User = get_user_model() user = get_user_model()
self.user = User.objects.create_user(
self.user = user.objects.create_user(
username='username', username='username',
email='user@email.com', email='user@email.com',
password='password' password='password'
@ -47,14 +50,104 @@ class CompanyViewTest(TestCase):
self.client.login(username='username', password='password') self.client.login(username='username', password='password')
def test_company_index(self): def post(self, url, data, valid=None):
""" Test the company index """ """
POST against this form and return the response (as a JSON object)
"""
response = self.client.post(url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
response = self.client.get(reverse('company-index'))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
json_data = json.loads(response.content)
# If a particular status code is required
if valid is not None:
if valid:
self.assertEqual(json_data['form_valid'], True)
else:
self.assertEqual(json_data['form_valid'], False)
form_errors = json.loads(json_data['form_errors'])
return json_data, form_errors
class SupplierPartViewTests(CompanyViewTestBase):
"""
Tests for the SupplierPart views.
"""
def test_supplier_part_create(self):
"""
Test the SupplierPartCreate view.
This view allows some additional functionality,
specifically it allows the user to create a single-quantity price break
automatically, when saving the new SupplierPart model.
"""
url = reverse('supplier-part-create')
# First check that we can GET the form
response = self.client.get(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# How many supplier parts are already in the database?
n = SupplierPart.objects.all().count()
data = {
'part': 1,
'supplier': 1,
}
# SKU is required! (form should fail)
(response, errors) = self.post(url, data, valid=False)
self.assertIsNotNone(errors.get('SKU', None))
data['SKU'] = 'TEST-ME-123'
(response, errors) = self.post(url, data, valid=True)
# Check that the SupplierPart was created!
self.assertEqual(n + 1, SupplierPart.objects.all().count())
# Check that it was created *without* a price-break
supplier_part = SupplierPart.objects.get(pk=response['pk'])
self.assertEqual(supplier_part.price_breaks.count(), 0)
# Duplicate SKU is prohibited
(response, errors) = self.post(url, data, valid=False)
self.assertIsNotNone(errors.get('__all__', None))
# Add with a different SKU, *and* a single-quantity price
data['SKU'] = 'TEST-ME-1234'
data['single_pricing_0'] = '123.4'
data['single_pricing_1'] = 'CAD'
(response, errors) = self.post(url, data, valid=True)
pk = response.get('pk')
# Check that *another* SupplierPart was created
self.assertEqual(n + 2, SupplierPart.objects.all().count())
supplier_part = SupplierPart.objects.get(pk=pk)
# Check that a price-break has been created!
self.assertEqual(supplier_part.price_breaks.count(), 1)
price_break = supplier_part.price_breaks.first()
self.assertEqual(price_break.quantity, 1)
def test_supplier_part_delete(self): def test_supplier_part_delete(self):
""" Test the SupplierPartDelete view """ """
Test the SupplierPartDelete view
"""
url = reverse('supplier-part-delete') url = reverse('supplier-part-delete')
@ -80,3 +173,30 @@ class CompanyViewTest(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(n - 2, SupplierPart.objects.count()) self.assertEqual(n - 2, SupplierPart.objects.count())
class CompanyViewTest(CompanyViewTestBase):
"""
Tests for various 'Company' views
"""
def test_company_index(self):
""" Test the company index """
response = self.client.get(reverse('company-index'))
self.assertEqual(response.status_code, 200)
def test_company_create(self):
"""
Test the view for creating a company
"""
# Check that different company types return different form titles
response = self.client.get(reverse('supplier-create'), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertContains(response, 'Create new Supplier')
response = self.client.get(reverse('manufacturer-create'), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertContains(response, 'Create new Manufacturer')
response = self.client.get(reverse('customer-create'), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertContains(response, 'Create new Customer')

View File

@ -1,4 +1,5 @@
from django.test import TestCase from django.test import TestCase
from django.core.exceptions import ValidationError
import os import os
@ -6,6 +7,9 @@ from .models import Company, Contact, SupplierPart
from .models import rename_company_image from .models import rename_company_image
from part.models import Part from part.models import Part
from InvenTree.exchange import InvenTreeManualExchangeBackend
from djmoney.contrib.exchange.models import Rate
class CompanySimpleTest(TestCase): class CompanySimpleTest(TestCase):
@ -32,6 +36,14 @@ class CompanySimpleTest(TestCase):
self.zerglphs = SupplierPart.objects.get(SKU='ZERGLPHS') self.zerglphs = SupplierPart.objects.get(SKU='ZERGLPHS')
self.zergm312 = SupplierPart.objects.get(SKU='ZERGM312') self.zergm312 = SupplierPart.objects.get(SKU='ZERGM312')
InvenTreeManualExchangeBackend().update_rates()
Rate.objects.create(
currency='AUD',
value='1.35',
backend_id='inventree',
)
def test_company_model(self): def test_company_model(self):
c = Company.objects.get(name='ABC Co.') c = Company.objects.get(name='ABC Co.')
self.assertEqual(c.name, 'ABC Co.') self.assertEqual(c.name, 'ABC Co.')
@ -108,6 +120,30 @@ class CompanySimpleTest(TestCase):
self.assertIsNone(m3x12.get_price_info(3)) self.assertIsNone(m3x12.get_price_info(3))
self.assertIsNotNone(m3x12.get_price_info(50)) self.assertIsNotNone(m3x12.get_price_info(50))
def test_currency_validation(self):
"""
Test validation for currency selection
"""
# Create a company with a valid currency code (should pass)
company = Company.objects.create(
name='Test',
description='Toast',
currency='AUD',
)
company.full_clean()
# Create a company with an invalid currency code (should fail)
company = Company.objects.create(
name='test',
description='Toasty',
currency='XZY',
)
with self.assertRaises(ValidationError):
company.full_clean()
class ContactSimpleTest(TestCase): class ContactSimpleTest(TestCase):

View File

@ -12,12 +12,12 @@ from django.views.generic import DetailView, ListView, UpdateView
from django.urls import reverse from django.urls import reverse
from django.forms import HiddenInput from django.forms import HiddenInput
from moneyed import CURRENCIES
from InvenTree.views import AjaxCreateView, AjaxUpdateView, AjaxDeleteView from InvenTree.views import AjaxCreateView, AjaxUpdateView, AjaxDeleteView
from InvenTree.helpers import str2bool from InvenTree.helpers import str2bool
from InvenTree.views import InvenTreeRoleMixin from InvenTree.views import InvenTreeRoleMixin
from common.models import Currency
from .models import Company from .models import Company
from .models import SupplierPart from .models import SupplierPart
from .models import SupplierPriceBreak from .models import SupplierPriceBreak
@ -29,6 +29,9 @@ from .forms import CompanyImageForm
from .forms import EditSupplierPartForm from .forms import EditSupplierPartForm
from .forms import EditPriceBreakForm from .forms import EditPriceBreakForm
import common.models
import common.settings
class CompanyIndex(InvenTreeRoleMixin, ListView): class CompanyIndex(InvenTreeRoleMixin, ListView):
""" View for displaying list of companies """ View for displaying list of companies
@ -268,6 +271,14 @@ class SupplierPartEdit(AjaxUpdateView):
ajax_form_title = _('Edit Supplier Part') ajax_form_title = _('Edit Supplier Part')
role_required = 'purchase_order.change' role_required = 'purchase_order.change'
def get_form(self):
form = super().get_form()
# Hide the single-pricing field (only for creating a new SupplierPart!)
form.fields['single_pricing'].widget = HiddenInput()
return form
class SupplierPartCreate(AjaxCreateView): class SupplierPartCreate(AjaxCreateView):
""" Create view for making new SupplierPart """ """ Create view for making new SupplierPart """
@ -279,6 +290,30 @@ class SupplierPartCreate(AjaxCreateView):
context_object_name = 'part' context_object_name = 'part'
role_required = 'purchase_order.add' role_required = 'purchase_order.add'
def validate(self, part, form):
single_pricing = form.cleaned_data.get('single_pricing', None)
if single_pricing:
# TODO - What validation steps can be performed on the single_pricing field?
pass
def save(self, form):
"""
If single_pricing is defined, add a price break for quantity=1
"""
# Save the supplier part object
supplier_part = super().save(form)
single_pricing = form.cleaned_data.get('single_pricing', None)
if single_pricing:
supplier_part.add_price_break(1, single_pricing)
return supplier_part
def get_form(self): def get_form(self):
""" Create Form instance to create a new SupplierPart object. """ Create Form instance to create a new SupplierPart object.
Hide some fields if they are not appropriate in context Hide some fields if they are not appropriate in context
@ -303,11 +338,14 @@ class SupplierPartCreate(AjaxCreateView):
supplier_id = self.get_param('supplier') supplier_id = self.get_param('supplier')
part_id = self.get_param('part') part_id = self.get_param('part')
supplier = None
if supplier_id: if supplier_id:
try: try:
initials['supplier'] = Company.objects.get(pk=supplier_id) supplier = Company.objects.get(pk=supplier_id)
initials['supplier'] = supplier
except (ValueError, Company.DoesNotExist): except (ValueError, Company.DoesNotExist):
pass supplier = None
if manufacturer_id: if manufacturer_id:
try: try:
@ -321,6 +359,17 @@ class SupplierPartCreate(AjaxCreateView):
except (ValueError, Part.DoesNotExist): except (ValueError, Part.DoesNotExist):
pass pass
# Initial value for single pricing
if supplier:
currency_code = supplier.currency_code
else:
currency_code = common.settings.currency_code_default()
currency = CURRENCIES.get(currency_code, None)
if currency_code:
initials['single_pricing'] = ('', currency)
return initials return initials
@ -417,10 +466,23 @@ class PriceBreakCreate(AjaxCreateView):
} }
def get_part(self): def get_part(self):
"""
Attempt to extract SupplierPart object from the supplied data.
"""
try: try:
return SupplierPart.objects.get(id=self.request.GET.get('part')) supplier_part = SupplierPart.objects.get(pk=self.request.GET.get('part'))
except SupplierPart.DoesNotExist: return supplier_part
return SupplierPart.objects.get(id=self.request.POST.get('part')) except (ValueError, SupplierPart.DoesNotExist):
pass
try:
supplier_part = SupplierPart.objects.get(pk=self.request.POST.get('part'))
return supplier_part
except (ValueError, SupplierPart.DoesNotExist):
pass
return None
def get_form(self): def get_form(self):
@ -433,14 +495,20 @@ class PriceBreakCreate(AjaxCreateView):
initials = super(AjaxCreateView, self).get_initial() initials = super(AjaxCreateView, self).get_initial()
supplier_part = self.get_part()
initials['part'] = self.get_part() initials['part'] = self.get_part()
# Pre-select the default currency if supplier_part is not None:
try: currency_code = supplier_part.supplier.currency_code
base = Currency.objects.get(base=True) else:
initials['currency'] = base currency_code = common.settings.currency_code_default()
except Currency.DoesNotExist:
pass # Extract the currency object associated with the code
currency = CURRENCIES.get(currency_code, None)
if currency:
initials['price'] = [1.0, currency]
return initials return initials

View File

@ -26,6 +26,17 @@ language: en-us
# Select an option from the "TZ database name" column # Select an option from the "TZ database name" column
timezone: UTC timezone: UTC
# List of currencies supported by default.
# Add other currencies here to allow use in InvenTree
currencies:
- AUD
- CAD
- EUR
- GBP
- JPY
- NZD
- USD
# Set debug to False to run in production mode # Set debug to False to run in production mode
debug: True debug: True

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -175,6 +175,7 @@ class EditPurchaseOrderLineItemForm(HelperForm):
'part', 'part',
'quantity', 'quantity',
'reference', 'reference',
'purchase_price',
'notes', 'notes',
] ]

View File

@ -0,0 +1,20 @@
# Generated by Django 3.0.7 on 2020-11-10 09:11
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('stock', '0054_remove_stockitem_build_order'),
('order', '0036_auto_20200831_0912'),
]
operations = [
migrations.AlterField(
model_name='salesorderallocation',
name='item',
field=models.ForeignKey(help_text='Select stock item to allocate', limit_choices_to={'belongs_to': None, 'part__salable': True, 'sales_order': None}, on_delete=django.db.models.deletion.CASCADE, related_name='sales_order_allocations', to='stock.StockItem'),
),
]

View File

@ -0,0 +1,24 @@
# Generated by Django 3.0.7 on 2020-11-12 06:37
from django.db import migrations
import djmoney.models.fields
class Migration(migrations.Migration):
dependencies = [
('order', '0037_auto_20201110_0911'),
]
operations = [
migrations.AddField(
model_name='purchaseorderlineitem',
name='purchase_price',
field=djmoney.models.fields.MoneyField(decimal_places=4, default_currency='USD', help_text='Unit purchase price', max_digits=19, null=True, verbose_name='Purchase Price'),
),
migrations.AddField(
model_name='purchaseorderlineitem',
name='purchase_price_currency',
field=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),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 3.0.7 on 2020-11-12 11:03
from django.db import migrations
import djmoney.models.fields
class Migration(migrations.Migration):
dependencies = [
('order', '0038_auto_20201112_1737'),
]
operations = [
migrations.AlterField(
model_name='purchaseorderlineitem',
name='purchase_price',
field=djmoney.models.fields.MoneyField(blank=True, decimal_places=4, default_currency='USD', help_text='Unit purchase price', max_digits=19, null=True, verbose_name='Purchase Price'),
),
]

View File

@ -4,6 +4,10 @@ Order model definitions
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import os
from datetime import datetime
from decimal import Decimal
from django.db import models, transaction from django.db import models, transaction
from django.db.models import F, Sum from django.db.models import F, Sum
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
@ -15,9 +19,7 @@ from django.utils.translation import ugettext as _
from markdownx.models import MarkdownxField from markdownx.models import MarkdownxField
import os from djmoney.models.fields import MoneyField
from datetime import datetime
from decimal import Decimal
from part import models as PartModels from part import models as PartModels
from stock import models as stock_models from stock import models as stock_models
@ -499,6 +501,15 @@ class PurchaseOrderLineItem(OrderLineItem):
received = models.DecimalField(decimal_places=5, max_digits=15, default=0, help_text=_('Number of items received')) received = models.DecimalField(decimal_places=5, max_digits=15, default=0, help_text=_('Number of items received'))
purchase_price = MoneyField(
max_digits=19,
decimal_places=4,
default_currency='USD',
null=True, blank=True,
verbose_name=_('Purchase Price'),
help_text=_('Unit purchase price'),
)
def remaining(self): def remaining(self):
""" Calculate the number of items remaining to be received """ """ Calculate the number of items remaining to be received """
r = self.quantity - self.received r = self.quantity - self.received
@ -621,7 +632,6 @@ class SalesOrderAllocation(models.Model):
'part__salable': True, 'part__salable': True,
'belongs_to': None, 'belongs_to': None,
'sales_order': None, 'sales_order': None,
'build_order': None,
}, },
help_text=_('Select stock item to allocate') help_text=_('Select stock item to allocate')
) )

View File

@ -95,6 +95,8 @@ class POLineItemSerializer(InvenTreeModelSerializer):
part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True) part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True)
supplier_part_detail = SupplierPartSerializer(source='part', many=False, read_only=True) supplier_part_detail = SupplierPartSerializer(source='part', many=False, read_only=True)
purchase_price_string = serializers.CharField(source='purchase_price', read_only=True)
class Meta: class Meta:
model = PurchaseOrderLineItem model = PurchaseOrderLineItem
@ -108,6 +110,9 @@ class POLineItemSerializer(InvenTreeModelSerializer):
'part_detail', 'part_detail',
'supplier_part_detail', 'supplier_part_detail',
'received', 'received',
'purchase_price',
'purchase_price_currency',
'purchase_price_string',
] ]

View File

@ -146,6 +146,7 @@ $("#po-table").inventreeTable({
field: 'part', field: 'part',
sortable: true, sortable: true,
title: '{% trans "Part" %}', title: '{% trans "Part" %}',
switchable: false,
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {
if (row.part) { if (row.part) {
return imageHoverIcon(row.part_detail.thumbnail) + renderLink(row.part_detail.full_name, `/part/${row.part_detail.pk}/`); return imageHoverIcon(row.part_detail.thumbnail) + renderLink(row.part_detail.full_name, `/part/${row.part_detail.pk}/`);
@ -177,9 +178,18 @@ $("#po-table").inventreeTable({
field: 'quantity', field: 'quantity',
title: '{% trans "Quantity" %}' title: '{% trans "Quantity" %}'
}, },
{
sortable: true,
field: 'purchase_price',
title: '{% trans "Unit Price" %}',
formatter: function(value, row) {
return row.purchase_price_string || row.purchase_price;
}
},
{ {
sortable: true, sortable: true,
field: 'received', field: 'received',
switchable: false,
title: '{% trans "Received" %}', title: '{% trans "Received" %}',
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {
return makeProgressBar(row.received, row.quantity, { return makeProgressBar(row.received, row.quantity, {
@ -203,6 +213,7 @@ $("#po-table").inventreeTable({
title: '{% trans "Notes" %}', title: '{% trans "Notes" %}',
}, },
{ {
switchable: false,
field: 'buttons', field: 'buttons',
title: '', title: '',
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {

View File

@ -23,8 +23,7 @@ class OrderTest(APITestCase):
def setUp(self): def setUp(self):
# Create a user for auth # Create a user for auth
User = get_user_model() get_user_model().objects.create_user('testuser', 'test@testing.com', 'password')
User.objects.create_user('testuser', 'test@testing.com', 'password')
self.client.login(username='testuser', password='password') self.client.login(username='testuser', password='password')
def doGet(self, url, options=''): def doGet(self, url, options=''):

View File

@ -120,12 +120,12 @@ class SalesOrderTest(TestCase):
# There should now be 4 stock items # There should now be 4 stock items
self.assertEqual(StockItem.objects.count(), 4) self.assertEqual(StockItem.objects.count(), 4)
Sa = StockItem.objects.get(pk=self.Sa.pk) sa = StockItem.objects.get(pk=self.Sa.pk)
Sb = StockItem.objects.get(pk=self.Sb.pk) sb = StockItem.objects.get(pk=self.Sb.pk)
# 25 units subtracted from each of the original items # 25 units subtracted from each of the original items
self.assertEqual(Sa.quantity, 75) self.assertEqual(sa.quantity, 75)
self.assertEqual(Sb.quantity, 175) self.assertEqual(sb.quantity, 175)
# And 2 items created which are associated with the order # And 2 items created which are associated with the order
outputs = StockItem.objects.filter(sales_order=self.order) outputs = StockItem.objects.filter(sales_order=self.order)
@ -134,8 +134,8 @@ class SalesOrderTest(TestCase):
for item in outputs.all(): for item in outputs.all():
self.assertEqual(item.quantity, 25) self.assertEqual(item.quantity, 25)
self.assertEqual(Sa.sales_order, None) self.assertEqual(sa.sales_order, None)
self.assertEqual(Sb.sales_order, None) self.assertEqual(sb.sales_order, None)
# And no allocations # And no allocations
self.assertEqual(SalesOrderAllocation.objects.count(), 0) self.assertEqual(SalesOrderAllocation.objects.count(), 0)

View File

@ -32,8 +32,7 @@ class OrderViewTestCase(TestCase):
super().setUp() super().setUp()
# Create a user # Create a user
User = get_user_model() user = get_user_model().objects.create_user('username', 'user@email.com', 'password')
user = User.objects.create_user('username', 'user@email.com', 'password')
# Ensure that the user has the correct permissions! # Ensure that the user has the correct permissions!
g = Group.objects.create(name='orders') g = Group.objects.create(name='orders')

View File

@ -12,6 +12,7 @@ from .models import PartCategory, Part
from .models import PartAttachment, PartStar, PartRelated from .models import PartAttachment, PartStar, PartRelated
from .models import BomItem from .models import BomItem
from .models import PartParameterTemplate, PartParameter from .models import PartParameterTemplate, PartParameter
from .models import PartCategoryParameterTemplate
from .models import PartTestTemplate from .models import PartTestTemplate
from .models import PartSellPriceBreak from .models import PartSellPriceBreak
@ -274,12 +275,17 @@ class ParameterAdmin(ImportExportModelAdmin):
list_display = ('part', 'template', 'data') list_display = ('part', 'template', 'data')
class PartCategoryParameterAdmin(admin.ModelAdmin):
pass
class PartSellPriceBreakAdmin(admin.ModelAdmin): class PartSellPriceBreakAdmin(admin.ModelAdmin):
class Meta: class Meta:
model = PartSellPriceBreak model = PartSellPriceBreak
list_display = ('part', 'quantity', 'cost', 'currency') list_display = ('part', 'quantity', 'price',)
admin.site.register(Part, PartAdmin) admin.site.register(Part, PartAdmin)
@ -290,5 +296,6 @@ admin.site.register(PartStar, PartStarAdmin)
admin.site.register(BomItem, BomItemAdmin) admin.site.register(BomItem, BomItemAdmin)
admin.site.register(PartParameterTemplate, ParameterTemplateAdmin) admin.site.register(PartParameterTemplate, ParameterTemplateAdmin)
admin.site.register(PartParameter, ParameterAdmin) admin.site.register(PartParameter, ParameterAdmin)
admin.site.register(PartCategoryParameterTemplate, PartCategoryParameterAdmin)
admin.site.register(PartTestTemplate, PartTestTemplateAdmin) admin.site.register(PartTestTemplate, PartTestTemplateAdmin)
admin.site.register(PartSellPriceBreak, PartSellPriceBreakAdmin) admin.site.register(PartSellPriceBreak, PartSellPriceBreakAdmin)

View File

@ -21,6 +21,7 @@ from .models import Part, PartCategory, BomItem, PartStar
from .models import PartParameter, PartParameterTemplate from .models import PartParameter, PartParameterTemplate
from .models import PartAttachment, PartTestTemplate from .models import PartAttachment, PartTestTemplate
from .models import PartSellPriceBreak from .models import PartSellPriceBreak
from .models import PartCategoryParameterTemplate
from build.models import Build from build.models import Build
@ -111,6 +112,36 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = PartCategory.objects.all() queryset = PartCategory.objects.all()
class CategoryParameters(generics.ListAPIView):
""" API endpoint for accessing a list of PartCategory objects.
- GET: Return a list of PartCategory objects
"""
queryset = PartCategoryParameterTemplate.objects.all()
serializer_class = part_serializers.CategoryParameterTemplateSerializer
def get_queryset(self):
"""
Custom filtering:
- Allow filtering by "null" parent to retrieve top-level part categories
"""
cat_id = self.kwargs.get('pk', None)
queryset = super().get_queryset()
if cat_id is not None:
try:
cat_id = int(cat_id)
queryset = queryset.filter(category=cat_id)
except ValueError:
pass
return queryset
class PartSalePriceList(generics.ListCreateAPIView): class PartSalePriceList(generics.ListCreateAPIView):
""" """
API endpoint for list view of PartSalePriceBreak model API endpoint for list view of PartSalePriceBreak model
@ -864,6 +895,7 @@ part_api_urls = [
# Base URL for PartCategory API endpoints # Base URL for PartCategory API endpoints
url(r'^category/', include([ url(r'^category/', include([
url(r'^(?P<pk>\d+)/parameters/?', CategoryParameters.as_view(), name='api-part-category-parameters'),
url(r'^(?P<pk>\d+)/?', CategoryDetail.as_view(), name='api-part-category-detail'), url(r'^(?P<pk>\d+)/?', CategoryDetail.as_view(), name='api-part-category-detail'),
url(r'^$', CategoryList.as_view(), name='api-part-category-list'), url(r'^$', CategoryList.as_view(), name='api-part-category-list'),
])), ])),

View File

@ -18,7 +18,7 @@
name: Thickness name: Thickness
units: mm units: mm
# And some parameters (requires part.yaml) # Add some parameters to parts (requires part.yaml)
- model: part.PartParameter - model: part.PartParameter
pk: 1 pk: 1
fields: fields:
@ -32,3 +32,18 @@
part: 2 part: 2
template: 1 template: 1
data: 12 data: 12
# Add some template parameters to categories (requires category.yaml)
- model: part.PartCategoryParameterTemplate
pk: 1
fields:
category: 7
parameter_template: 1
default_value: '2.8'
- model: part.PartCategoryParameterTemplate
pk: 2
fields:
category: 7
parameter_template: 3
default_value: '0.5'

View File

@ -8,6 +8,7 @@
category: 8 category: 8
link: www.acme.com/parts/m2x4lphs link: www.acme.com/parts/m2x4lphs
tree_id: 0 tree_id: 0
purchaseable: True
level: 0 level: 0
lft: 0 lft: 0
rght: 0 rght: 0

View File

@ -16,11 +16,10 @@ from django.utils.translation import ugettext as _
from .models import Part, PartCategory, PartAttachment, PartRelated from .models import Part, PartCategory, PartAttachment, PartRelated
from .models import BomItem from .models import BomItem
from .models import PartParameterTemplate, PartParameter from .models import PartParameterTemplate, PartParameter
from .models import PartCategoryParameterTemplate
from .models import PartTestTemplate from .models import PartTestTemplate
from .models import PartSellPriceBreak from .models import PartSellPriceBreak
from common.models import Currency
class PartModelChoiceField(forms.ModelChoiceField): class PartModelChoiceField(forms.ModelChoiceField):
""" Extending string representation of Part instance with available stock """ """ Extending string representation of Part instance with available stock """
@ -201,10 +200,22 @@ class EditPartForm(HelperForm):
help_text=_('Confirm part creation'), help_text=_('Confirm part creation'),
widget=forms.HiddenInput()) widget=forms.HiddenInput())
selected_category_templates = forms.BooleanField(required=False,
initial=False,
label=_('Include category parameter templates'),
widget=forms.HiddenInput())
parent_category_templates = forms.BooleanField(required=False,
initial=False,
label=_('Include parent categories parameter templates'),
widget=forms.HiddenInput())
class Meta: class Meta:
model = Part model = Part
fields = [ fields = [
'category', 'category',
'selected_category_templates',
'parent_category_templates',
'name', 'name',
'IPN', 'IPN',
'description', 'description',
@ -266,6 +277,28 @@ class EditCategoryForm(HelperForm):
] ]
class EditCategoryParameterTemplateForm(HelperForm):
""" Form for editing a PartCategoryParameterTemplate object """
add_to_same_level_categories = forms.BooleanField(required=False,
initial=False,
help_text=_('Add parameter template to same level categories'))
add_to_all_categories = forms.BooleanField(required=False,
initial=False,
help_text=_('Add parameter template to all categories'))
class Meta:
model = PartCategoryParameterTemplate
fields = [
'category',
'parameter_template',
'default_value',
'add_to_same_level_categories',
'add_to_all_categories',
]
class EditBomItemForm(HelperForm): class EditBomItemForm(HelperForm):
""" Form for editing a BomItem object """ """ Form for editing a BomItem object """
@ -298,13 +331,10 @@ class PartPriceForm(forms.Form):
help_text=_('Input quantity for price calculation') help_text=_('Input quantity for price calculation')
) )
currency = forms.ModelChoiceField(queryset=Currency.objects.all(), label='Currency', help_text=_('Select currency for price calculation'))
class Meta: class Meta:
model = Part model = Part
fields = [ fields = [
'quantity', 'quantity',
'currency',
] ]
@ -315,13 +345,10 @@ class EditPartSalePriceBreakForm(HelperForm):
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5) quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5)
cost = RoundingDecimalFormField(max_digits=10, decimal_places=5)
class Meta: class Meta:
model = PartSellPriceBreak model = PartSellPriceBreak
fields = [ fields = [
'part', 'part',
'quantity', 'quantity',
'cost', 'price',
'currency',
] ]

View File

@ -1,19 +0,0 @@
# Generated by Django 3.0.7 on 2020-10-27 04:57
import InvenTree.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('part', '0051_bomitem_optional'),
]
operations = [
migrations.AlterField(
model_name='part',
name='link',
field=InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True),
),
]

View File

@ -2,7 +2,7 @@
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import InvenTree.fields
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -19,4 +19,9 @@ class Migration(migrations.Migration):
('part_2', models.ForeignKey(help_text='Select Related Part', on_delete=django.db.models.deletion.DO_NOTHING, related_name='related_parts_2', to='part.Part')), ('part_2', models.ForeignKey(help_text='Select Related Part', on_delete=django.db.models.deletion.DO_NOTHING, related_name='related_parts_2', to='part.Part')),
], ],
), ),
migrations.AlterField(
model_name='part',
name='link',
field=InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True),
),
] ]

View File

@ -1,14 +0,0 @@
# Generated by Django 3.0.7 on 2020-11-03 10:28
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('part', '0052_auto_20201027_1557'),
('part', '0052_partrelated'),
]
operations = [
]

View File

@ -0,0 +1,27 @@
# Generated by Django 3.0.7 on 2020-10-30 18:04
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('part', '0052_partrelated'),
]
operations = [
migrations.CreateModel(
name='PartCategoryParameterTemplate',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('default_value', models.CharField(blank=True, help_text='Default Parameter Value', max_length=500)),
('category', models.ForeignKey(help_text='Part Category', on_delete=django.db.models.deletion.CASCADE, related_name='parameter_templates', to='part.PartCategory')),
('parameter_template', models.ForeignKey(help_text='Parameter Template', on_delete=django.db.models.deletion.CASCADE, related_name='part_categories', to='part.PartParameterTemplate')),
],
),
migrations.AddConstraint(
model_name='partcategoryparametertemplate',
constraint=models.UniqueConstraint(fields=('category', 'parameter_template'), name='unique_category_parameter_template_pair'),
),
]

View File

@ -7,7 +7,7 @@ import part.settings
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('part', '0053_merge_20201103_1028'), ('part', '0052_partrelated'),
] ]
operations = [ operations = [

View File

@ -0,0 +1,24 @@
# Generated by Django 3.0.7 on 2020-11-10 10:01
from django.db import migrations
import djmoney.models.fields
class Migration(migrations.Migration):
dependencies = [
('part', '0054_auto_20201109_1246'),
]
operations = [
migrations.AddField(
model_name='partsellpricebreak',
name='price',
field=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'),
),
migrations.AddField(
model_name='partsellpricebreak',
name='price_currency',
field=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),
),
]

View File

@ -0,0 +1,147 @@
# Generated by Django 3.0.7 on 2020-11-10 11:25
from django.db import migrations
from moneyed import CURRENCIES
from django.db import migrations, connection
from company.models import SupplierPriceBreak
def migrate_currencies(apps, schema_editor):
"""
Migrate from the 'old' method of handling currencies,
to the new method which uses the django-money library.
Previously, we created a custom Currency model,
which was very simplistic.
Here we will attempt to map each existing "currency" reference
for the SupplierPriceBreak model, to a new django-money compatible currency.
"""
print("Updating currency references for SupplierPriceBreak model...")
# A list of available currency codes
currency_codes = CURRENCIES.keys()
cursor = connection.cursor()
# The 'suffix' field denotes the currency code
response = cursor.execute('SELECT id, suffix, description from common_currency;')
results = cursor.fetchall()
remap = {}
for index, row in enumerate(results):
pk, suffix, description = row
suffix = suffix.strip().upper()
if suffix not in currency_codes:
print("Missing suffix:", suffix)
while suffix not in currency_codes:
# Ask the user to input a valid currency
print(f"Could not find a valid currency matching '{suffix}'.")
print("Please enter a valid currency code")
suffix = str(input("> ")).strip()
if pk not in remap.keys():
remap[pk] = suffix
# Now iterate through each PartSellPriceBreak and update the rows
response = cursor.execute('SELECT id, cost, currency_id, price, price_currency from part_partsellpricebreak;')
results = cursor.fetchall()
count = 0
for index, row in enumerate(results):
pk, cost, currency_id, price, price_currency = row
# Copy the 'cost' field across to the 'price' field
response = cursor.execute(f'UPDATE part_partsellpricebreak set price={cost} where id={pk};')
# Extract the updated currency code
currency_code = remap.get(currency_id, 'USD')
# Update the currency code
response = cursor.execute(f'UPDATE part_partsellpricebreak set price_currency= "{currency_code}" where id={pk};')
count += 1
print(f"Updated {count} SupplierPriceBreak rows")
def reverse_currencies(apps, schema_editor):
"""
Reverse the "update" process.
Here we may be in the situation that the legacy "Currency" table is empty,
and so we have to re-populate it based on the new price_currency codes.
"""
print("Reversing currency migration...")
cursor = connection.cursor()
# Extract a list of currency codes which are in use
response = cursor.execute(f'SELECT id, price, price_currency from part_partsellpricebreak;')
results = cursor.fetchall()
codes_in_use = set()
for index, row in enumerate(results):
pk, price, code = row
codes_in_use.add(code)
# Copy the 'price' field back into the 'cost' field
response = cursor.execute(f'UPDATE part_partsellpricebreak set cost={price} where id={pk};')
# Keep a dict of which currency objects map to which code
code_map = {}
# For each currency code in use, check if we have a matching Currency object
for code in codes_in_use:
response = cursor.execute(f'SELECT id, suffix from common_currency where suffix="{code}";')
row = response.fetchone()
if row is not None:
# A match exists!
pk, suffix = row
code_map[suffix] = pk
else:
# No currency object exists!
description = CURRENCIES[code]
# Create a new object in the database
print(f"Creating new Currency object for {code}")
# Construct a query to create a new Currency object
query = f'INSERT into common_currency (symbol, suffix, description, value, base) VALUES ("$", "{code}", "{description}", 1.0, False);'
response = cursor.execute(query)
code_map[code] = cursor.lastrowid
# Ok, now we know how each suffix maps to a Currency object
for suffix in code_map.keys():
pk = code_map[suffix]
# Update the table to point to the Currency objects
print(f"Currency {suffix} -> pk {pk}")
response = cursor.execute(f'UPDATE part_partsellpricebreak set currency_id={pk} where price_currency="{suffix}";')
class Migration(migrations.Migration):
dependencies = [
('part', '0055_auto_20201110_1001'),
]
operations = [
migrations.RunPython(migrate_currencies, reverse_code=reverse_currencies),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 3.0.7 on 2020-11-10 11:40
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('part', '0056_auto_20201110_1125'),
]
operations = [
migrations.RemoveField(
model_name='partsellpricebreak',
name='currency',
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 3.0.7 on 2020-11-10 11:42
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('part', '0057_remove_partsellpricebreak_currency'),
]
operations = [
migrations.RemoveField(
model_name='partsellpricebreak',
name='cost',
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 3.0.7 on 2020-11-12 00:12
import InvenTree.fields
import django.core.validators
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('part', '0058_remove_partsellpricebreak_cost'),
]
operations = [
migrations.AlterField(
model_name='partsellpricebreak',
name='quantity',
field=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'),
),
]

View File

@ -0,0 +1,14 @@
# Generated by Django 3.0.7 on 2020-11-12 06:22
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('part', '0053_partcategoryparametertemplate'),
('part', '0059_auto_20201112_1112'),
]
operations = [
]

View File

@ -12,7 +12,8 @@ from django.core.exceptions import ValidationError
from django.urls import reverse from django.urls import reverse
from django.db import models, transaction from django.db import models, transaction
from django.db.models import Sum from django.db.utils import IntegrityError
from django.db.models import Sum, UniqueConstraint
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
@ -164,6 +165,26 @@ class PartCategory(InvenTreeTree):
return category_parameters return category_parameters
@classmethod
def get_parent_categories(cls):
""" Return tuple list of parent (root) categories """
# Get root nodes
root_categories = cls.objects.filter(level=0)
parent_categories = []
for category in root_categories:
parent_categories.append((category.id, category.name))
return parent_categories
def get_parameter_templates(self):
""" Return parameter templates associated to category """
prefetch = PartCategoryParameterTemplate.objects.prefetch_related('category', 'parameter_template')
return prefetch.filter(category=self.id)
@receiver(pre_delete, sender=PartCategory, dispatch_uid='partcategory_delete_log') @receiver(pre_delete, sender=PartCategory, dispatch_uid='partcategory_delete_log')
def before_delete_part_category(sender, instance, using, **kwargs): def before_delete_part_category(sender, instance, using, **kwargs):
@ -307,6 +328,9 @@ class Part(MPTTModel):
If not, it is considered "orphaned" and will be deleted. If not, it is considered "orphaned" and will be deleted.
""" """
# Get category templates settings
add_category_templates = kwargs.pop('add_category_templates', None)
if self.pk: if self.pk:
previous = Part.objects.get(pk=self.pk) previous = Part.objects.get(pk=self.pk)
@ -322,6 +346,44 @@ class Part(MPTTModel):
super().save(*args, **kwargs) super().save(*args, **kwargs)
if add_category_templates:
# Get part category
category = self.category
if add_category_templates:
# Store templates added to part
template_list = []
# Create part parameters for selected category
category_templates = add_category_templates['main']
if category_templates:
for template in category.get_parameter_templates():
parameter = PartParameter.create(part=self,
template=template.parameter_template,
data=template.default_value,
save=True)
if parameter:
template_list.append(template.parameter_template)
# Create part parameters for parent category
category_templates = add_category_templates['parent']
if category_templates:
# Get parent categories
parent_categories = category.get_ancestors()
for category in parent_categories:
for template in category.get_parameter_templates():
# Check that template wasn't already added
if template.parameter_template not in template_list:
try:
PartParameter.create(part=self,
template=template.parameter_template,
data=template.default_value,
save=True)
except IntegrityError:
# PartParameter already exists
pass
def __str__(self): def __str__(self):
return f"{self.full_name} - {self.description}" return f"{self.full_name} - {self.description}"
@ -1664,6 +1726,49 @@ class PartParameter(models.Model):
return part_parameter return part_parameter
class PartCategoryParameterTemplate(models.Model):
"""
A PartCategoryParameterTemplate creates a unique relationship between a PartCategory
and a PartParameterTemplate.
Multiple PartParameterTemplate instances can be associated to a PartCategory to drive
a default list of parameter templates attached to a Part instance upon creation.
Attributes:
category: Reference to a single PartCategory object
parameter_template: Reference to a single PartParameterTemplate object
default_value: The default value for the parameter in the context of the selected
category
"""
class Meta:
constraints = [
UniqueConstraint(fields=['category', 'parameter_template'],
name='unique_category_parameter_template_pair')
]
def __str__(self):
""" String representation of a PartCategoryParameterTemplate (admin interface) """
if self.default_value:
return f'{self.category.name} | {self.parameter_template.name} | {self.default_value}'
else:
return f'{self.category.name} | {self.parameter_template.name}'
category = models.ForeignKey(PartCategory,
on_delete=models.CASCADE,
related_name='parameter_templates',
help_text=_('Part Category'))
parameter_template = models.ForeignKey(PartParameterTemplate,
on_delete=models.CASCADE,
related_name='part_categories',
help_text=_('Parameter Template'))
default_value = models.CharField(max_length=500,
blank=True,
help_text=_('Default Parameter Value'))
class BomItem(models.Model): class BomItem(models.Model):
""" A BomItem links a part to its component items. """ A BomItem links a part to its component items.
A part can have a BOM (bill of materials) which defines A part can have a BOM (bill of materials) which defines

View File

@ -15,7 +15,7 @@ from stock.models import StockItem
from .models import (BomItem, Part, PartAttachment, PartCategory, from .models import (BomItem, Part, PartAttachment, PartCategory,
PartParameter, PartParameterTemplate, PartSellPriceBreak, PartParameter, PartParameterTemplate, PartSellPriceBreak,
PartStar, PartTestTemplate) PartStar, PartTestTemplate, PartCategoryParameterTemplate)
class CategorySerializer(InvenTreeModelSerializer): class CategorySerializer(InvenTreeModelSerializer):
@ -84,13 +84,9 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
Serializer for sale prices for Part model. Serializer for sale prices for Part model.
""" """
symbol = serializers.CharField(read_only=True)
suffix = serializers.CharField(read_only=True)
quantity = serializers.FloatField() quantity = serializers.FloatField()
cost = serializers.FloatField() price = serializers.CharField()
class Meta: class Meta:
model = PartSellPriceBreak model = PartSellPriceBreak
@ -98,10 +94,7 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
'pk', 'pk',
'part', 'part',
'quantity', 'quantity',
'cost', 'price',
'currency',
'symbol',
'suffix',
] ]
@ -425,3 +418,21 @@ class PartParameterTemplateSerializer(InvenTreeModelSerializer):
'name', 'name',
'units', 'units',
] ]
class CategoryParameterTemplateSerializer(InvenTreeModelSerializer):
""" Serializer for PartCategoryParameterTemplate """
parameter_template_detail = PartParameterTemplateSerializer(source='parameter_template',
many=False,
read_only=True)
class Meta:
model = PartCategoryParameterTemplate
fields = [
'pk',
'category',
'parameter_template',
'parameter_template_detail',
'default_value',
]

View File

@ -10,7 +10,9 @@
<hr> <hr>
<div id='price-break-toolbar' class='btn-group'> <div id='price-break-toolbar' class='btn-group'>
<button class='btn btn-primary' id='new-price-break' type='button'>{% trans "Add Price Break" %}</button> <button class='btn btn-primary' id='new-price-break' type='button'>
<span class='fas fa-plus-circle'></span> {% trans "Add Price Break" %}
</button>
</div> </div>
<table class='table table-striped table-condensed' id='price-break-table' data-toolbar='#price-break-toolbar'> <table class='table table-striped table-condensed' id='price-break-table' data-toolbar='#price-break-toolbar'>
@ -81,18 +83,11 @@ $('#price-break-table').inventreeTable({
sortable: true, sortable: true,
}, },
{ {
field: 'cost', field: 'price',
title: '{% trans "Price" %}', title: '{% trans "Price" %}',
sortable: true, sortable: true,
formatter: function(value, row, index) { formatter: function(value, row, index) {
var html = ''; var html = value;
html += row.symbol || '';
html += value;
if (row.suffix) {
html += ' ' + row.suffix || '';
}
html += `<div class='btn-group float-right' role='group'>` html += `<div class='btn-group float-right' role='group'>`

View File

@ -29,8 +29,9 @@ class PartAPITest(APITestCase):
def setUp(self): def setUp(self):
# Create a user for auth # Create a user for auth
User = get_user_model() user = get_user_model()
self.user = User.objects.create_user(
self.user = user.objects.create_user(
username='testuser', username='testuser',
email='test@testing.com', email='test@testing.com',
password='password' password='password'
@ -269,8 +270,9 @@ class PartAPIAggregationTest(APITestCase):
def setUp(self): def setUp(self):
# Create a user for auth # Create a user for auth
User = get_user_model() user = get_user_model()
User.objects.create_user('testuser', 'test@testing.com', 'password')
user.objects.create_user('testuser', 'test@testing.com', 'password')
self.client.login(username='testuser', password='password') self.client.login(username='testuser', password='password')

View File

@ -165,14 +165,14 @@ class CategoryTest(TestCase):
self.assert_equal(p.get_default_location().pathstring, 'Office/Drawer_1') self.assert_equal(p.get_default_location().pathstring, 'Office/Drawer_1')
# Any part under electronics should default to 'Home' # Any part under electronics should default to 'Home'
R1 = Part.objects.get(name='R_2K2_0805') r1 = Part.objects.get(name='R_2K2_0805')
self.assertIsNone(R1.default_location) self.assertIsNone(r1.default_location)
self.assertEqual(R1.get_default_location().name, 'Home') self.assertEqual(r1.get_default_location().name, 'Home')
# But one part has a default_location set # But one part has a default_location set
R2 = Part.objects.get(name='R_4K7_0603') r2 = Part.objects.get(name='R_4K7_0603')
self.assertEqual(R2.get_default_location().name, 'Bathroom') self.assertEqual(r2.get_default_location().name, 'Bathroom')
# And one part should have no default location at all # And one part should have no default location at all
W = Part.objects.get(name='Widget') w = Part.objects.get(name='Widget')
self.assertIsNone(W.get_default_location()) self.assertIsNone(w.get_default_location())

View File

@ -3,10 +3,12 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from django.test import TestCase from django.test import TestCase, TransactionTestCase
import django.core.exceptions as django_exceptions import django.core.exceptions as django_exceptions
from .models import Part, PartCategory
from .models import PartParameter, PartParameterTemplate from .models import PartParameter, PartParameterTemplate
from .models import PartCategoryParameterTemplate
class TestParams(TestCase): class TestParams(TestCase):
@ -24,7 +26,10 @@ class TestParams(TestCase):
self.assertEquals(str(t1), 'Length (mm)') self.assertEquals(str(t1), 'Length (mm)')
p1 = PartParameter.objects.get(pk=1) p1 = PartParameter.objects.get(pk=1)
self.assertEqual(str(p1), "M2x4 LPHS : Length = 4mm") self.assertEqual(str(p1), 'M2x4 LPHS : Length = 4mm')
c1 = PartCategoryParameterTemplate.objects.get(pk=1)
self.assertEqual(str(c1), 'Mechanical | Length | 2.8')
def test_validate(self): def test_validate(self):
@ -40,3 +45,47 @@ class TestParams(TestCase):
t3 = PartParameterTemplate(name='aBcde', units='dd') t3 = PartParameterTemplate(name='aBcde', units='dd')
t3.full_clean() t3.full_clean()
t3.save() t3.save()
class TestCategoryTemplates(TransactionTestCase):
fixtures = [
'location',
'category',
'part',
'params'
]
def test_validate(self):
# Category templates
n = PartCategoryParameterTemplate.objects.all().count()
self.assertEqual(n, 2)
category = PartCategory.objects.get(pk=8)
t1 = PartParameterTemplate.objects.get(pk=2)
c1 = PartCategoryParameterTemplate(category=category,
parameter_template=t1,
default_value='xyz')
c1.save()
n = PartCategoryParameterTemplate.objects.all().count()
self.assertEqual(n, 3)
# Get test part
part = Part.objects.get(pk=1)
# Get part parameters count
n_param = part.get_parameters().count()
add_category_templates = {
'main': True,
'parent': True,
}
# Save it with category parameters
part.save(**{'add_category_templates': add_category_templates})
# Check new part parameters count
# Only 2 parameters should be added as one already existed with same template
self.assertEqual(n_param + 2, part.get_parameters().count())

View File

@ -54,10 +54,10 @@ class PartTest(TestCase):
] ]
def setUp(self): def setUp(self):
self.R1 = Part.objects.get(name='R_2K2_0805') self.r1 = Part.objects.get(name='R_2K2_0805')
self.R2 = Part.objects.get(name='R_4K7_0603') self.r2 = Part.objects.get(name='R_4K7_0603')
self.C1 = Part.objects.get(name='C_22N_0805') self.c1 = Part.objects.get(name='C_22N_0805')
Part.objects.rebuild() Part.objects.rebuild()
@ -78,18 +78,18 @@ class PartTest(TestCase):
self.assertEqual(str(p), "BOB | Bob | A2 - Can we build it?") self.assertEqual(str(p), "BOB | Bob | A2 - Can we build it?")
def test_metadata(self): def test_metadata(self):
self.assertEqual(self.R1.name, 'R_2K2_0805') self.assertEqual(self.r1.name, 'R_2K2_0805')
self.assertEqual(self.R1.get_absolute_url(), '/part/3/') self.assertEqual(self.r1.get_absolute_url(), '/part/3/')
def test_category(self): def test_category(self):
self.assertEqual(str(self.C1.category), 'Electronics/Capacitors - Capacitors') self.assertEqual(str(self.c1.category), 'Electronics/Capacitors - Capacitors')
orphan = Part.objects.get(name='Orphan') orphan = Part.objects.get(name='Orphan')
self.assertIsNone(orphan.category) self.assertIsNone(orphan.category)
self.assertEqual(orphan.category_path, '') self.assertEqual(orphan.category_path, '')
def test_rename_img(self): def test_rename_img(self):
img = rename_part_image(self.R1, 'hello.png') img = rename_part_image(self.r1, 'hello.png')
self.assertEqual(img, os.path.join('part_images', 'hello.png')) self.assertEqual(img, os.path.join('part_images', 'hello.png'))
def test_stock(self): def test_stock(self):
@ -100,12 +100,12 @@ class PartTest(TestCase):
self.assertEqual(r.available_stock, 0) self.assertEqual(r.available_stock, 0)
def test_barcode(self): def test_barcode(self):
barcode = self.R1.format_barcode() barcode = self.r1.format_barcode()
self.assertIn('InvenTree', barcode) self.assertIn('InvenTree', barcode)
self.assertIn(self.R1.name, barcode) self.assertIn(self.r1.name, barcode)
def test_copy(self): def test_copy(self):
self.R2.deep_copy(self.R1, image=True, bom=True) self.r2.deep_copy(self.r1, image=True, bom=True)
def test_match_names(self): def test_match_names(self):
@ -181,9 +181,9 @@ class PartSettingsTest(TestCase):
def setUp(self): def setUp(self):
# Create a user for auth # Create a user for auth
User = get_user_model() user = get_user_model()
self.user = User.objects.create_user( self.user = user.objects.create_user(
username='testuser', username='testuser',
email='test@testing.com', email='test@testing.com',
password='password', password='password',

View File

@ -23,8 +23,9 @@ class PartViewTestCase(TestCase):
super().setUp() super().setUp()
# Create a user # Create a user
User = get_user_model() user = get_user_model()
self.user = User.objects.create_user(
self.user = user.objects.create_user(
username='username', username='username',
email='user@email.com', email='user@email.com',
password='password' password='password'

View File

@ -80,10 +80,18 @@ part_detail_urls = [
url(r'^.*$', views.PartDetail.as_view(), name='part-detail'), url(r'^.*$', views.PartDetail.as_view(), name='part-detail'),
] ]
category_parameter_urls = [
url(r'^new/', views.CategoryParameterTemplateCreate.as_view(), name='category-param-template-create'),
url(r'^(?P<pid>\d+)/edit/', views.CategoryParameterTemplateEdit.as_view(), name='category-param-template-edit'),
url(r'^(?P<pid>\d+)/delete/', views.CategoryParameterTemplateDelete.as_view(), name='category-param-template-delete'),
]
part_category_urls = [ part_category_urls = [
url(r'^edit/?', views.CategoryEdit.as_view(), name='category-edit'), url(r'^edit/?', views.CategoryEdit.as_view(), name='category-edit'),
url(r'^delete/?', views.CategoryDelete.as_view(), name='category-delete'), url(r'^delete/?', views.CategoryDelete.as_view(), name='category-delete'),
url(r'^parameters/', include(category_parameter_urls)),
url(r'^parametric/?', views.CategoryParametric.as_view(), name='category-parametric'), url(r'^parametric/?', views.CategoryParametric.as_view(), name='category-parametric'),
url(r'^.*$', views.CategoryDetail.as_view(), name='category-detail'), url(r'^.*$', views.CategoryDetail.as_view(), name='category-detail'),
] ]

View File

@ -7,6 +7,7 @@ from __future__ import unicode_literals
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import transaction from django.db import transaction
from django.db.utils import IntegrityError
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.shortcuts import HttpResponseRedirect from django.shortcuts import HttpResponseRedirect
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -16,6 +17,8 @@ from django.forms.models import model_to_dict
from django.forms import HiddenInput, CheckboxInput from django.forms import HiddenInput, CheckboxInput
from django.conf import settings from django.conf import settings
from moneyed import CURRENCIES
import os import os
from rapidfuzz import fuzz from rapidfuzz import fuzz
@ -23,12 +26,13 @@ from decimal import Decimal, InvalidOperation
from .models import PartCategory, Part, PartAttachment, PartRelated from .models import PartCategory, Part, PartAttachment, PartRelated
from .models import PartParameterTemplate, PartParameter from .models import PartParameterTemplate, PartParameter
from .models import PartCategoryParameterTemplate
from .models import BomItem from .models import BomItem
from .models import match_part_names from .models import match_part_names
from .models import PartTestTemplate from .models import PartTestTemplate
from .models import PartSellPriceBreak from .models import PartSellPriceBreak
from common.models import Currency, InvenTreeSetting from common.models import InvenTreeSetting
from company.models import SupplierPart from company.models import SupplierPart
from . import forms as part_forms from . import forms as part_forms
@ -625,6 +629,10 @@ class PartCreate(AjaxCreateView):
# Hide the default_supplier field (there are no matching supplier parts yet!) # Hide the default_supplier field (there are no matching supplier parts yet!)
form.fields['default_supplier'].widget = HiddenInput() form.fields['default_supplier'].widget = HiddenInput()
# Display category templates widgets
form.fields['selected_category_templates'].widget = CheckboxInput()
form.fields['parent_category_templates'].widget = CheckboxInput()
return form return form
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
@ -667,7 +675,14 @@ class PartCreate(AjaxCreateView):
# Record the user who created this part # Record the user who created this part
part.creation_user = request.user part.creation_user = request.user
part.save() # Store category templates settings
add_category_templates = {
'main': form.cleaned_data['selected_category_templates'],
'parent': form.cleaned_data['parent_category_templates'],
}
# Save part and pass category template settings
part.save(**{'add_category_templates': add_category_templates})
data['pk'] = part.pk data['pk'] = part.pk
data['text'] = str(part) data['text'] = str(part)
@ -700,6 +715,10 @@ class PartCreate(AjaxCreateView):
if label in self.request.GET: if label in self.request.GET:
initials[label] = self.request.GET.get(label) initials[label] = self.request.GET.get(label)
# Automatically create part parameters from category templates
initials['selected_category_templates'] = str2bool(InvenTreeSetting.get_setting('PART_CATEGORY_PARAMETERS', False))
initials['parent_category_templates'] = initials['selected_category_templates']
return initials return initials
@ -1860,19 +1879,12 @@ class PartPricing(AjaxView):
if quantity < 1: if quantity < 1:
quantity = 1 quantity = 1
if currency is None: # TODO - Capacity for price comparison in different currencies
# No currency selected? Try to select a default one
try:
currency = Currency.objects.get(base=1)
except Currency.DoesNotExist:
currency = None currency = None
# Currency scaler # Currency scaler
scaler = Decimal(1.0) scaler = Decimal(1.0)
if currency is not None:
scaler = Decimal(currency.value)
part = self.get_part() part = self.get_part()
ctx = { ctx = {
@ -1942,12 +1954,7 @@ class PartPricing(AjaxView):
except ValueError: except ValueError:
quantity = 1 quantity = 1
try: # TODO - How to handle pricing in different currencies?
currency_id = int(self.request.POST.get('currency', None))
if currency_id:
currency = Currency.objects.get(pk=currency_id)
except (ValueError, Currency.DoesNotExist):
currency = None currency = None
# Always mark the form as 'invalid' (the user may wish to keep getting pricing data) # Always mark the form as 'invalid' (the user may wish to keep getting pricing data)
@ -2215,6 +2222,185 @@ class CategoryCreate(AjaxCreateView):
return initials return initials
class CategoryParameterTemplateCreate(AjaxCreateView):
""" View for creating a new PartCategoryParameterTemplate """
role_required = 'part.add'
model = PartCategoryParameterTemplate
form_class = part_forms.EditCategoryParameterTemplateForm
ajax_form_title = _('Create Category Parameter Template')
def get_initial(self):
""" Get initial data for Category """
initials = super().get_initial()
category_id = self.kwargs.get('pk', None)
if category_id:
try:
initials['category'] = PartCategory.objects.get(pk=category_id)
except (PartCategory.DoesNotExist, ValueError):
pass
return initials
def get_form(self):
""" Create a form to upload a new CategoryParameterTemplate
- Hide the 'category' field (parent part)
- Display parameter templates which are not yet related
"""
form = super(AjaxCreateView, self).get_form()
form.fields['category'].widget = HiddenInput()
if form.is_valid():
form.cleaned_data['category'] = self.kwargs.get('pk', None)
try:
# Get selected category
category = self.get_initial()['category']
# Get existing parameter templates
parameters = [template.parameter_template.pk
for template in category.get_parameter_templates()]
# Exclude templates already linked to category
updated_choices = []
for choice in form.fields["parameter_template"].choices:
if (choice[0] not in parameters):
updated_choices.append(choice)
# Update choices for parameter templates
form.fields['parameter_template'].choices = updated_choices
except KeyError:
pass
return form
def post(self, request, *args, **kwargs):
""" Capture the POST request
- If the add_to_all_categories object is set, link parameter template to
all categories
- If the add_to_same_level_categories object is set, link parameter template to
same level categories
"""
form = self.get_form()
valid = form.is_valid()
if valid:
add_to_same_level_categories = form.cleaned_data['add_to_same_level_categories']
add_to_all_categories = form.cleaned_data['add_to_all_categories']
selected_category = PartCategory.objects.get(pk=int(self.kwargs['pk']))
parameter_template = form.cleaned_data['parameter_template']
default_value = form.cleaned_data['default_value']
categories = PartCategory.objects.all()
if add_to_same_level_categories and not add_to_all_categories:
# Get level
level = selected_category.level
# Filter same level categories
categories = categories.filter(level=level)
if add_to_same_level_categories or add_to_all_categories:
# Add parameter template and default value to categories
for category in categories:
# Skip selected category (will be processed in the post call)
if category.pk != selected_category.pk:
try:
cat_template = PartCategoryParameterTemplate.objects.create(category=category,
parameter_template=parameter_template,
default_value=default_value)
cat_template.save()
except IntegrityError:
# Parameter template is already linked to category
pass
return super().post(request, *args, **kwargs)
class CategoryParameterTemplateEdit(AjaxUpdateView):
""" View for editing a PartCategoryParameterTemplate """
role_required = 'part.change'
model = PartCategoryParameterTemplate
form_class = part_forms.EditCategoryParameterTemplateForm
ajax_form_title = _('Edit Category Parameter Template')
def get_object(self):
try:
self.object = self.model.objects.get(pk=self.kwargs['pid'])
except:
return None
return self.object
def get_form(self):
""" Create a form to upload a new CategoryParameterTemplate
- Hide the 'category' field (parent part)
- Display parameter templates which are not yet related
"""
form = super(AjaxUpdateView, self).get_form()
form.fields['category'].widget = HiddenInput()
form.fields['add_to_all_categories'].widget = HiddenInput()
form.fields['add_to_same_level_categories'].widget = HiddenInput()
if form.is_valid():
form.cleaned_data['category'] = self.kwargs.get('pk', None)
try:
# Get selected category
category = PartCategory.objects.get(pk=self.kwargs.get('pk', None))
# Get selected template
selected_template = self.get_object().parameter_template
# Get existing parameter templates
parameters = [template.parameter_template.pk
for template in category.get_parameter_templates()
if template.parameter_template.pk != selected_template.pk]
# Exclude templates already linked to category
updated_choices = []
for choice in form.fields["parameter_template"].choices:
if (choice[0] not in parameters):
updated_choices.append(choice)
# Update choices for parameter templates
form.fields['parameter_template'].choices = updated_choices
# Set initial choice to current template
form.fields['parameter_template'].initial = selected_template
except KeyError:
pass
return form
class CategoryParameterTemplateDelete(AjaxDeleteView):
""" View for deleting an existing PartCategoryParameterTemplate """
role_required = 'part.delete'
model = PartCategoryParameterTemplate
ajax_form_title = _("Delete Category Parameter Template")
def get_object(self):
try:
self.object = self.model.objects.get(pk=self.kwargs['pid'])
except:
return None
return self.object
class BomItemDetail(InvenTreeRoleMixin, DetailView): class BomItemDetail(InvenTreeRoleMixin, DetailView):
""" Detail view for BomItem """ """ Detail view for BomItem """
context_object_name = 'item' context_object_name = 'item'
@ -2393,12 +2579,11 @@ class PartSalePriceBreakCreate(AjaxCreateView):
initials['part'] = self.get_part() initials['part'] = self.get_part()
# Pre-select the default currency default_currency = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY')
try: currency = CURRENCIES.get(default_currency, None)
base = Currency.objects.get(base=True)
initials['currency'] = base if currency is not None:
except Currency.DoesNotExist: initials['price'] = [1.0, currency]
pass
return initials return initials

View File

@ -83,8 +83,6 @@ class StockItemResource(ModelResource):
sales_order = Field(attribute='sales_order', widget=widgets.ForeignKeyWidget(SalesOrder)) sales_order = Field(attribute='sales_order', widget=widgets.ForeignKeyWidget(SalesOrder))
build_order = Field(attribute='build_order', widget=widgets.ForeignKeyWidget(Build))
purchase_order = Field(attribute='purchase_order', widget=widgets.ForeignKeyWidget(PurchaseOrder)) purchase_order = Field(attribute='purchase_order', widget=widgets.ForeignKeyWidget(PurchaseOrder))
# Date management # Date management

View File

@ -488,11 +488,7 @@ class StockList(generics.ListCreateAPIView):
if build: if build:
queryset = queryset.filter(build=build) queryset = queryset.filter(build=build)
build_order = params.get('build_order', None) # Filter by 'is building' status
if build_order:
queryset = queryset.filter(build_order=build_order)
is_building = params.get('is_building', None) is_building = params.get('is_building', None)
if is_building: if is_building:
@ -621,10 +617,10 @@ class StockList(generics.ListCreateAPIView):
queryset = queryset.exclude(quantity__lte=0) queryset = queryset.exclude(quantity__lte=0)
# Filter by internal part number # Filter by internal part number
IPN = params.get('IPN', None) ipn = params.get('IPN', None)
if IPN is not None: if ipn is not None:
queryset = queryset.filter(part__IPN=IPN) queryset = queryset.filter(part__IPN=ipn)
# Does the client wish to filter by the Part ID? # Does the client wish to filter by the Part ID?
part_id = params.get('part', None) part_id = params.get('part', None)

View File

@ -124,6 +124,7 @@ class CreateStockItemForm(HelperForm):
fields = [ fields = [
'part', 'part',
'supplier_part', 'supplier_part',
'purchase_price',
'location', 'location',
'quantity', 'quantity',
'batch', 'batch',
@ -399,6 +400,7 @@ class EditStockItemForm(HelperForm):
'serial', 'serial',
'batch', 'batch',
'status', 'status',
'purchase_price',
'link', 'link',
'delete_on_deplete', 'delete_on_deplete',
] ]

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,17 @@
# Generated by Django 3.0.7 on 2020-11-10 09:11
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('stock', '0053_auto_20201110_0513'),
]
operations = [
migrations.RemoveField(
model_name='stockitem',
name='build_order',
),
]

View File

@ -24,6 +24,8 @@ from markdownx.models import MarkdownxField
from mptt.models import MPTTModel, TreeForeignKey from mptt.models import MPTTModel, TreeForeignKey
from djmoney.models.fields import MoneyField
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from datetime import datetime from datetime import datetime
from InvenTree import helpers from InvenTree import helpers
@ -134,14 +136,13 @@ class StockItem(MPTTModel):
purchase_order: Link to a PurchaseOrder (if this stock item was created from a PurchaseOrder) purchase_order: Link to a PurchaseOrder (if this stock item was created from a PurchaseOrder)
infinite: If True this StockItem can never be exhausted infinite: If True this StockItem can never be exhausted
sales_order: Link to a SalesOrder object (if the StockItem has been assigned to a SalesOrder) sales_order: Link to a SalesOrder object (if the StockItem has been assigned to a SalesOrder)
build_order: Link to a BuildOrder object (if the StockItem has been assigned to a BuildOrder) purchase_price: The unit purchase price for this StockItem - this is the unit price at time of purchase (if this item was purchased from an external supplier)
""" """
# A Query filter which will be re-used in multiple places to determine if a StockItem is actually "in stock" # A Query filter which will be re-used in multiple places to determine if a StockItem is actually "in stock"
IN_STOCK_FILTER = Q( IN_STOCK_FILTER = Q(
quantity__gt=0, quantity__gt=0,
sales_order=None, sales_order=None,
build_order=None,
belongs_to=None, belongs_to=None,
customer=None, customer=None,
is_building=False, is_building=False,
@ -427,14 +428,6 @@ class StockItem(MPTTModel):
related_name='stock_items', related_name='stock_items',
null=True, blank=True) null=True, blank=True)
build_order = models.ForeignKey(
'build.Build',
on_delete=models.SET_NULL,
verbose_name=_("Destination Build Order"),
related_name='stock_items',
null=True, blank=True
)
# last time the stock was checked / counted # last time the stock was checked / counted
stocktake_date = models.DateField(blank=True, null=True) stocktake_date = models.DateField(blank=True, null=True)
@ -456,6 +449,15 @@ class StockItem(MPTTModel):
help_text=_('Stock Item Notes') help_text=_('Stock Item Notes')
) )
purchase_price = MoneyField(
max_digits=19,
decimal_places=4,
default_currency='USD',
null=True,
verbose_name=_('Purchase Price'),
help_text=_('Single unit purchase price at time of purchase'),
)
def clearAllocations(self): def clearAllocations(self):
""" """
Clear all order allocations for this StockItem: Clear all order allocations for this StockItem:
@ -602,9 +604,6 @@ class StockItem(MPTTModel):
if self.sales_order is not None: if self.sales_order is not None:
return False return False
if self.build_order is not None:
return False
return True return True
def installedItemCount(self): def installedItemCount(self):
@ -622,12 +621,12 @@ class StockItem(MPTTModel):
return self.installedItemCount() > 0 return self.installedItemCount() > 0
@transaction.atomic @transaction.atomic
def installStockItem(self, otherItem, quantity, user, notes): def installStockItem(self, other_item, quantity, user, notes):
""" """
Install another stock item into this stock item. Install another stock item into this stock item.
Args Args
otherItem: The stock item to install into this stock item other_item: The stock item to install into this stock item
quantity: The quantity of stock to install quantity: The quantity of stock to install
user: The user performing the operation user: The user performing the operation
notes: Any notes associated with the operation notes: Any notes associated with the operation
@ -638,10 +637,10 @@ class StockItem(MPTTModel):
return False return False
# If the quantity is less than the stock item, split the stock! # If the quantity is less than the stock item, split the stock!
stock_item = otherItem.splitStock(quantity, None, user) stock_item = other_item.splitStock(quantity, None, user)
if stock_item is None: if stock_item is None:
stock_item = otherItem stock_item = other_item
# Assign the other stock item into this one # Assign the other stock item into this one
stock_item.belongs_to = self stock_item.belongs_to = self
@ -738,10 +737,6 @@ class StockItem(MPTTModel):
if self.sales_order is not None: if self.sales_order is not None:
return False return False
# Not 'in stock' if it has been allocated to a BuildOrder
if self.build_order is not None:
return False
# Not 'in stock' if it has been assigned to a customer # Not 'in stock' if it has been assigned to a customer
if self.customer is not None: if self.customer is not None:
return False return False

View File

@ -73,7 +73,6 @@ class StockItemSerializer(InvenTreeModelSerializer):
return queryset.prefetch_related( return queryset.prefetch_related(
'belongs_to', 'belongs_to',
'build', 'build',
'build_order',
'customer', 'customer',
'sales_order', 'sales_order',
'supplier_part', 'supplier_part',
@ -155,7 +154,6 @@ class StockItemSerializer(InvenTreeModelSerializer):
'batch', 'batch',
'belongs_to', 'belongs_to',
'build', 'build',
'build_order',
'customer', 'customer',
'in_stock', 'in_stock',
'is_building', 'is_building',

View File

@ -221,12 +221,6 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
<td>{% trans "Sales Order" %}</td> <td>{% trans "Sales Order" %}</td>
<td><a href="{% url 'so-detail' item.sales_order.id %}">{{ item.sales_order.reference }}</a> - <a href="{% url 'company-detail' item.sales_order.customer.id %}">{{ item.sales_order.customer.name }}</a></td> <td><a href="{% url 'so-detail' item.sales_order.id %}">{{ item.sales_order.reference }}</a> - <a href="{% url 'company-detail' item.sales_order.customer.id %}">{{ item.sales_order.customer.name }}</a></td>
</tr> </tr>
{% elif item.build_order %}
<tr>
<td><span class='fas fa-tools'></span></td>
<td>{% trans "Build Order" %}</td>
<td><a href="{% url 'build-detail' item.build_order.id %}">{{ item.build_order }}</a></td>
</tr>
{% else %} {% else %}
<tr> <tr>
<td><span class='fas fa-map-marker-alt'></span></td> <td><span class='fas fa-map-marker-alt'></span></td>
@ -266,6 +260,13 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
<td><a href="{% url 'po-detail' item.purchase_order.id %}">{{ item.purchase_order }}</a></td> <td><a href="{% url 'po-detail' item.purchase_order.id %}">{{ item.purchase_order }}</a></td>
</tr> </tr>
{% endif %} {% endif %}
{% if item.purchase_price %}
<tr>
<td><span class='fas fa-dollar-sign'></span></td>
<td>{% trans "Purchase Price" %}</td>
<td>{{ item.purchase_price }}</td>
</tr>
{% endif %}
{% if item.parent %} {% if item.parent %}
<tr> <tr>
<td><span class='fas fa-sitemap'></span></td> <td><span class='fas fa-sitemap'></span></td>

View File

@ -22,8 +22,9 @@ class StockAPITestCase(APITestCase):
def setUp(self): def setUp(self):
# Create a user for auth # Create a user for auth
User = get_user_model() user = get_user_model()
self.user = User.objects.create_user('testuser', 'test@testing.com', 'password')
self.user = user.objects.create_user('testuser', 'test@testing.com', 'password')
# Add the necessary permissions to the user # Add the necessary permissions to the user
perms = [ perms = [

View File

@ -23,8 +23,9 @@ class StockViewTestCase(TestCase):
super().setUp() super().setUp()
# Create a user # Create a user
User = get_user_model() user = get_user_model()
self.user = User.objects.create_user(
self.user = user.objects.create_user(
username='username', username='username',
email='user@email.com', email='user@email.com',
password='password' password='password'

View File

@ -38,12 +38,12 @@ class StockTest(TestCase):
self.drawer3 = StockLocation.objects.get(name='Drawer_3') self.drawer3 = StockLocation.objects.get(name='Drawer_3')
# Create a user # Create a user
User = get_user_model() user = get_user_model()
User.objects.create_user('username', 'user@email.com', 'password') user.objects.create_user('username', 'user@email.com', 'password')
self.client.login(username='username', password='password') self.client.login(username='username', password='password')
self.user = User.objects.get(username='username') self.user = user.objects.get(username='username')
# Ensure the MPTT objects are correctly rebuild # Ensure the MPTT objects are correctly rebuild
Part.objects.rebuild() Part.objects.rebuild()
@ -221,21 +221,21 @@ class StockTest(TestCase):
def test_split_stock(self): def test_split_stock(self):
# Split the 1234 x 2K2 resistors in Drawer_1 # Split the 1234 x 2K2 resistors in Drawer_1
N = StockItem.objects.filter(part=3).count() n = StockItem.objects.filter(part=3).count()
stock = StockItem.objects.get(id=1234) stock = StockItem.objects.get(id=1234)
stock.splitStock(1000, None, self.user) stock.splitStock(1000, None, self.user)
self.assertEqual(stock.quantity, 234) self.assertEqual(stock.quantity, 234)
# There should be a new stock item too! # There should be a new stock item too!
self.assertEqual(StockItem.objects.filter(part=3).count(), N + 1) self.assertEqual(StockItem.objects.filter(part=3).count(), n + 1)
# Try to split a negative quantity # Try to split a negative quantity
stock.splitStock(-10, None, self.user) stock.splitStock(-10, None, self.user)
self.assertEqual(StockItem.objects.filter(part=3).count(), N + 1) self.assertEqual(StockItem.objects.filter(part=3).count(), n + 1)
stock.splitStock(stock.quantity, None, self.user) stock.splitStock(stock.quantity, None, self.user)
self.assertEqual(StockItem.objects.filter(part=3).count(), N + 1) self.assertEqual(StockItem.objects.filter(part=3).count(), n + 1)
def test_stocktake(self): def test_stocktake(self):
# Perform stocktake # Perform stocktake

View File

@ -14,6 +14,8 @@ from django.urls import reverse
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from moneyed import CURRENCIES
from InvenTree.views import AjaxView from InvenTree.views import AjaxView
from InvenTree.views import AjaxUpdateView, AjaxDeleteView, AjaxCreateView from InvenTree.views import AjaxUpdateView, AjaxDeleteView, AjaxCreateView
from InvenTree.views import QRCodeView from InvenTree.views import QRCodeView
@ -32,6 +34,8 @@ from report.models import TestReport
from label.models import StockItemLabel from label.models import StockItemLabel
from .models import StockItem, StockLocation, StockItemTracking, StockItemAttachment, StockItemTestResult from .models import StockItem, StockLocation, StockItemTracking, StockItemAttachment, StockItemTestResult
import common.settings
from .admin import StockItemResource from .admin import StockItemResource
from . import forms as StockForms from . import forms as StockForms
@ -1572,6 +1576,8 @@ class StockItemCreate(AjaxCreateView):
initials['location'] = part.get_default_location() initials['location'] = part.get_default_location()
initials['supplier_part'] = part.default_supplier initials['supplier_part'] = part.default_supplier
currency_code = common.settings.currency_code_default()
# SupplierPart field has been specified # SupplierPart field has been specified
# It must match the Part, if that has been supplied # It must match the Part, if that has been supplied
if sup_part_id: if sup_part_id:
@ -1581,6 +1587,8 @@ class StockItemCreate(AjaxCreateView):
if part is None or supplier_part.part == part: if part is None or supplier_part.part == part:
initials['supplier_part'] = supplier_part initials['supplier_part'] = supplier_part
currency_code = supplier_part.supplier.currency_code
except (ValueError, SupplierPart.DoesNotExist): except (ValueError, SupplierPart.DoesNotExist):
pass pass
@ -1592,6 +1600,11 @@ class StockItemCreate(AjaxCreateView):
except (ValueError, StockLocation.DoesNotExist): except (ValueError, StockLocation.DoesNotExist):
pass pass
currency = CURRENCIES.get(currency_code, None)
if currency:
initials['purchase_price'] = (None, currency)
return initials return initials
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):

View File

@ -0,0 +1,114 @@
{% extends "InvenTree/settings/settings.html" %}
{% load i18n %}
{% block tabs %}
{% include "InvenTree/settings/tabs.html" with tab='category' %}
{% endblock %}
{% block subtitle %}
{% trans "Category Settings" %}
{% endblock %}
{% block settings %}
<form action="{% url 'settings-category' %}" method="post">
{% csrf_token %}
{% load crispy_forms_tags %}
<div id="category-select">
{% crispy form %}
</div>
</form>
{% if category %}
<hr>
<h4>{% trans "Category Parameter Templates" %}</h4>
<div id='param-buttons'>
<button class='btn btn-success' id='new-param'>
<span class='fas fa-plus-circle'></span> {% trans "New Parameter" %}
</button>
</div>
<table class='table table-striped table-condensed' id='param-table' data-toolbar='#param-buttons'>
</table>
{% endif %}
{% endblock %}
{% block js_ready %}
{{ block.super }}
{# Convert dropdown to select2 format #}
$(document).ready(function() {
attachSelect('#category-select');
});
{% if category %}
$("#param-table").inventreeTable({
url: "{% url 'api-part-category-parameters' category.pk %}",
queryParams: {
ordering: 'name',
},
formatNoMatches: function() { return '{% trans "No category parameter templates found" %}'; },
columns: [
{
field: 'pk',
title: 'ID',
visible: false,
switchable: false,
},
{
field: 'parameter_template_detail.name',
title: '{% trans "Parameter Template" %}',
sortable: 'true',
},
{
field: 'default_value',
title: '{% trans "Default Value" %}',
sortable: 'true',
formatter: function(value, row, index, field) {
var bEdit = "<button title='{% trans "Edit Template" %}' class='template-edit btn btn-default btn-glyph' type='button' pk='" + row.pk + "'><span class='fas fa-edit'></span></button>";
var bDel = "<button title='{% trans "Delete Template" %}' class='template-delete btn btn-default btn-glyph' type='button' pk='" + row.pk + "'><span class='fas fa-trash-alt icon-red'></span></button>";
var html = value
html += "<div class='btn-group float-right' role='group'>" + bEdit + bDel + "</div>";
return html;
}
}
]
});
$("#new-param").click(function() {
launchModalForm("{% url 'category-param-template-create' category.pk %}", {
success: function() {
$("#param-table").bootstrapTable('refresh');
},
});
});
$("#param-table").on('click', '.template-edit', function() {
var button = $(this);
var url = "/part/category/{{ category.pk }}/parameters/" + button.attr('pk') + "/edit/";
launchModalForm(url, {
success: function() {
$("#param-table").bootstrapTable('refresh');
}
});
});
$("#param-table").on('click', '.template-delete', function() {
var button = $(this);
var url = "/part/category/{{ category.pk }}/parameters/" + button.attr('pk') + "/delete/";
launchModalForm(url, {
success: function() {
$("#param-table").bootstrapTable('refresh');
}
});
});
{% endif %}
{% endblock %}

View File

@ -1,118 +0,0 @@
{% extends "InvenTree/settings/settings.html" %}
{% load i18n %}
{% block subtitle %}
{% trans "General Settings" %}
{% endblock %}
{% block tabs %}
{% include "InvenTree/settings/tabs.html" with tab='currency' %}
{% endblock %}
{% block settings %}
<h4>{% trans "Currencies" %}</h4>
<div id='currency-buttons'>
<button class='btn btn-success' id='new-currency'>
<span class='fas fa-plus-circle'></span> {% trans "New Currency" %}</button>
</div>
<table class='table table-striped table-condensed' id='currency-table' data-toolbar='#currency-buttons'>
</table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
$("#currency-table").inventreeTable({
url: "{% url 'api-currency-list' %}",
queryParams: {
ordering: 'suffix'
},
formatNoMatches: function() { return "No currencies found"; },
rowStyle: function(row, index) {
if (row.base) {
return {classes: 'basecurrency'};
} else {
return {};
}
},
columns: [
{
field: 'pk',
title: 'ID',
visible: false,
switchable: false,
},
{
field: 'symbol',
title: 'Symbol',
},
{
field: 'suffix',
title: 'Currency',
sortable: true,
},
{
field: 'description',
title: 'Description',
sortable: true,
},
{
field: 'value',
title: 'Value',
sortable: true,
formatter: function(value, row, index, field) {
if (row.base) {
return "Base Currency";
} else {
return value;
}
}
},
{
formatter: function(value, row, index, field) {
var bEdit = "<button title='Edit Currency' class='cur-edit btn btn-default btn-glyph' type='button' pk='" + row.pk + "'><span class='fas fa-edit'></span></button>";
var bDel = "<button title='Delete Currency' class='cur-delete btn btn-default btn-glyph' type='button' pk='" + row.pk + "'><span class='fas fa-trash-alt icon-red'></span></button>";
var html = "<div class='btn-group' role='group'>" + bEdit + bDel + "</div>";
return html;
}
}
]
});
$("#currency-table").on('click', '.cur-edit', function() {
var button = $(this);
var url = "/common/currency/" + button.attr('pk') + "/edit/";
launchModalForm(url, {
success: function() {
$("#currency-table").bootstrapTable('refresh');
},
});
});
$("#currency-table").on('click', '.cur-delete', function() {
var button = $(this);
var url = "/common/currency/" + button.attr('pk') + "/delete/";
launchModalForm(url, {
success: function() {
$("#currency-table").bootstrapTable('refresh');
},
});
});
$("#new-currency").click(function() {
launchModalForm("{% url 'currency-create' %}", {
success: function() {
$("#currency-table").bootstrapTable('refresh');
},
});
});
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More