mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'currency-support'
This commit is contained in:
commit
ffdf03ddcf
@ -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
|
13
.travis.yml
13
.travis.yml
@ -30,11 +30,24 @@ before_install:
|
||||
script:
|
||||
- cd InvenTree && python3 manage.py makemigrations && cd ..
|
||||
- python3 ci/check_migration_files.py
|
||||
# Run unit testing / code coverage tests
|
||||
- invoke coverage
|
||||
# Run unit test for SQL database backend
|
||||
- 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 ..
|
||||
- invoke translate
|
||||
- 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:
|
||||
- coveralls
|
21
InvenTree/InvenTree/exchange.py
Normal file
21
InvenTree/InvenTree/exchange.py
Normal 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 {}
|
@ -12,6 +12,7 @@ from crispy_forms.layout import Layout, Field
|
||||
from crispy_forms.bootstrap import PrependedText, AppendedText, PrependedAppendedText, StrictButton, Div
|
||||
from django.contrib.auth.models import User
|
||||
from common.models import ColorTheme
|
||||
from part.models import PartCategory
|
||||
|
||||
|
||||
class HelperForm(forms.ModelForm):
|
||||
@ -200,3 +201,33 @@ class ColorThemeSelectForm(forms.ModelForm):
|
||||
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',
|
||||
),
|
||||
)
|
||||
|
@ -155,8 +155,9 @@ INSTALLED_APPS = [
|
||||
'markdownify', # Markdown template rendering
|
||||
'django_tex', # LaTeX output
|
||||
'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
|
||||
|
||||
]
|
||||
|
||||
LOGGING = {
|
||||
@ -356,6 +357,17 @@ LANGUAGES = [
|
||||
('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 = (
|
||||
os.path.join(BASE_DIR, 'locale/'),
|
||||
)
|
||||
|
@ -27,8 +27,7 @@ class APITests(APITestCase):
|
||||
def setUp(self):
|
||||
|
||||
# Create a user (but do not log in!)
|
||||
User = get_user_model()
|
||||
User.objects.create_user(self.username, 'user@email.com', self.password)
|
||||
get_user_model().objects.create_user(self.username, 'user@email.com', self.password)
|
||||
|
||||
def basicAuth(self):
|
||||
# Use basic authentication
|
||||
|
@ -16,8 +16,7 @@ class ViewTests(TestCase):
|
||||
def setUp(self):
|
||||
|
||||
# Create a user
|
||||
User = get_user_model()
|
||||
User.objects.create_user(self.username, 'user@email.com', self.password)
|
||||
get_user_model().objects.create_user(self.username, 'user@email.com', self.password)
|
||||
|
||||
self.client.login(username=self.username, password=self.password)
|
||||
|
||||
|
@ -36,7 +36,8 @@ from django.views.generic.base import RedirectView
|
||||
from rest_framework.documentation import include_docs_urls
|
||||
|
||||
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 common.views import SettingEdit
|
||||
@ -74,7 +75,7 @@ settings_urls = [
|
||||
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'^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'^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'),
|
||||
|
@ -6,11 +6,22 @@ from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from moneyed import CURRENCIES
|
||||
|
||||
import common.models
|
||||
|
||||
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():
|
||||
""" Return the list of allowable URL schemes.
|
||||
In addition to the default schemes allowed by Django,
|
||||
|
@ -24,7 +24,8 @@ from stock.models import StockLocation, StockItem
|
||||
from common.models import InvenTreeSetting, ColorTheme
|
||||
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 rest_framework import views
|
||||
@ -750,6 +751,42 @@ class ColorThemeSelectView(FormView):
|
||||
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):
|
||||
""" View for displaying database statistics """
|
||||
|
||||
|
@ -24,8 +24,8 @@ class BarcodeAPITest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
# Create a user for auth
|
||||
User = get_user_model()
|
||||
User.objects.create_user('testuser', 'test@testing.com', 'password')
|
||||
user = get_user_model()
|
||||
user.objects.create_user('testuser', 'test@testing.com', 'password')
|
||||
|
||||
self.client.login(username='testuser', password='password')
|
||||
|
||||
|
20
InvenTree/build/migrations/0023_auto_20201110_0911.py
Normal file
20
InvenTree/build/migrations/0023_auto_20201110_0911.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -962,7 +962,6 @@ class BuildItem(models.Model):
|
||||
related_name='allocations',
|
||||
help_text=_('Source stock item'),
|
||||
limit_choices_to={
|
||||
'build_order': None,
|
||||
'sales_order': None,
|
||||
'belongs_to': None,
|
||||
}
|
||||
|
@ -267,17 +267,17 @@ class BuildTest(TestCase):
|
||||
# New stock items should have been created!
|
||||
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!
|
||||
with self.assertRaises(StockItem.DoesNotExist):
|
||||
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
|
||||
self.assertEqual(A.quantity, 900)
|
||||
self.assertEqual(C.quantity, 4500)
|
||||
self.assertEqual(a.quantity, 900)
|
||||
self.assertEqual(c.quantity, 4500)
|
||||
|
||||
# And 10 new stock items created for the build output
|
||||
outputs = StockItem.objects.filter(build=self.build)
|
||||
|
@ -28,10 +28,10 @@ class BuildTestSimple(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
# Create a user for auth
|
||||
User = get_user_model()
|
||||
User.objects.create_user('testuser', 'test@testing.com', 'password')
|
||||
user = get_user_model()
|
||||
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')
|
||||
self.user.groups.add(g)
|
||||
@ -109,11 +109,11 @@ class TestBuildAPI(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
# Create a user for auth
|
||||
User = get_user_model()
|
||||
user = User.objects.create_user('testuser', 'test@testing.com', 'password')
|
||||
user = get_user_model()
|
||||
self.user = user.objects.create_user('testuser', 'test@testing.com', 'password')
|
||||
|
||||
g = Group.objects.create(name='builders')
|
||||
user.groups.add(g)
|
||||
self.user.groups.add(g)
|
||||
|
||||
for rule in g.rule_sets.all():
|
||||
if rule.name == 'build':
|
||||
@ -185,11 +185,11 @@ class TestBuildViews(TestCase):
|
||||
super().setUp()
|
||||
|
||||
# Create a user
|
||||
User = get_user_model()
|
||||
user = User.objects.create_user('username', 'user@email.com', 'password')
|
||||
user = get_user_model()
|
||||
self.user = user.objects.create_user('username', 'user@email.com', 'password')
|
||||
|
||||
g = Group.objects.create(name='builders')
|
||||
user.groups.add(g)
|
||||
self.user.groups.add(g)
|
||||
|
||||
for rule in g.rule_sets.all():
|
||||
if rule.name == 'build':
|
||||
|
@ -5,11 +5,7 @@ from django.contrib import admin
|
||||
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
|
||||
from .models import Currency, InvenTreeSetting
|
||||
|
||||
|
||||
class CurrencyAdmin(ImportExportModelAdmin):
|
||||
list_display = ('symbol', 'suffix', 'description', 'value', 'base')
|
||||
from .models import InvenTreeSetting
|
||||
|
||||
|
||||
class SettingsAdmin(ImportExportModelAdmin):
|
||||
@ -17,5 +13,4 @@ class SettingsAdmin(ImportExportModelAdmin):
|
||||
list_display = ('key', 'value')
|
||||
|
||||
|
||||
admin.site.register(Currency, CurrencyAdmin)
|
||||
admin.site.register(InvenTreeSetting, SettingsAdmin)
|
||||
|
@ -5,35 +5,5 @@ Provides a JSON API for common components.
|
||||
# -*- coding: utf-8 -*-
|
||||
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 = [
|
||||
url(r'^currency/?$', CurrencyList.as_view(), name='api-currency-list'),
|
||||
]
|
||||
|
@ -1,92 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.db.utils import OperationalError, ProgrammingError, IntegrityError
|
||||
|
||||
|
||||
class CommonConfig(AppConfig):
|
||||
name = 'common'
|
||||
|
||||
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
|
||||
|
@ -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
|
13
InvenTree/common/fixtures/settings.yaml
Normal file
13
InvenTree/common/fixtures/settings.yaml
Normal 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"
|
@ -7,21 +7,7 @@ from __future__ import unicode_literals
|
||||
|
||||
from InvenTree.forms import HelperForm
|
||||
|
||||
from .models import Currency, InvenTreeSetting
|
||||
|
||||
|
||||
class CurrencyEditForm(HelperForm):
|
||||
""" Form for creating / editing a currency object """
|
||||
|
||||
class Meta:
|
||||
model = Currency
|
||||
fields = [
|
||||
'symbol',
|
||||
'suffix',
|
||||
'description',
|
||||
'value',
|
||||
'base'
|
||||
]
|
||||
from .models import InvenTreeSetting
|
||||
|
||||
|
||||
class SettingEditForm(HelperForm):
|
||||
|
18
InvenTree/common/migrations/0009_delete_currency.py
Normal file
18
InvenTree/common/migrations/0009_delete_currency.py
Normal 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',
|
||||
),
|
||||
]
|
@ -7,13 +7,18 @@ These models are 'generic' and do not fit a particular business logic object.
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import os
|
||||
import decimal
|
||||
|
||||
from django.db import models
|
||||
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.core.validators import MinValueValidator, MaxValueValidator
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
import InvenTree.helpers
|
||||
@ -59,6 +64,13 @@ class InvenTreeSetting(models.Model):
|
||||
'default': 'My company name',
|
||||
},
|
||||
|
||||
'INVENTREE_DEFAULT_CURRENCY': {
|
||||
'name': _('Default Currency'),
|
||||
'description': _('Default currency'),
|
||||
'default': 'USD',
|
||||
'choices': djmoney.settings.CURRENCY_CHOICES,
|
||||
},
|
||||
|
||||
'PART_IPN_REGEX': {
|
||||
'name': _('IPN Regex'),
|
||||
'description': _('Regular expression pattern for matching Part IPN')
|
||||
@ -92,6 +104,13 @@ class InvenTreeSetting(models.Model):
|
||||
'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': {
|
||||
'name': _('Component'),
|
||||
'description': _('Parts can be used as sub-components by default'),
|
||||
@ -226,6 +245,29 @@ class InvenTreeSetting(models.Model):
|
||||
else:
|
||||
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
|
||||
def get_setting_object(cls, key):
|
||||
"""
|
||||
@ -239,12 +281,20 @@ class InvenTreeSetting(models.Model):
|
||||
|
||||
try:
|
||||
setting = InvenTreeSetting.objects.filter(key__iexact=key).first()
|
||||
except (InvenTreeSetting.DoesNotExist):
|
||||
# Create the setting if it does not exist
|
||||
setting = InvenTreeSetting.create(
|
||||
key=key,
|
||||
value=InvenTreeSetting.get_default_value(key)
|
||||
)
|
||||
except OperationalError:
|
||||
# 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(
|
||||
key=key,
|
||||
value=InvenTreeSetting.get_default_value(key)
|
||||
)
|
||||
except OperationalError:
|
||||
# Settings table has not been created yet
|
||||
setting = None
|
||||
|
||||
return setting
|
||||
|
||||
@ -396,6 +446,13 @@ class InvenTreeSetting(models.Model):
|
||||
except InvenTreeSetting.DoesNotExist:
|
||||
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):
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
"""
|
||||
Represents a PriceBreak model
|
||||
@ -491,32 +480,39 @@ class PriceBreak(models.Model):
|
||||
class Meta:
|
||||
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)
|
||||
|
||||
@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):
|
||||
def convert_to(self, currency_code):
|
||||
"""
|
||||
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:
|
||||
scaler = self.currency.value
|
||||
|
||||
return self.cost * scaler
|
||||
return converted.amount
|
||||
|
||||
|
||||
class ColorTheme(models.Model):
|
||||
|
@ -1,22 +1,3 @@
|
||||
"""
|
||||
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'
|
||||
]
|
||||
|
23
InvenTree/common/settings.py
Normal file
23
InvenTree/common/settings.py
Normal 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
|
@ -4,20 +4,7 @@ from __future__ import unicode_literals
|
||||
from django.test import TestCase
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from .models import Currency, 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)
|
||||
from .models import InvenTreeSetting
|
||||
|
||||
|
||||
class SettingsTest(TestCase):
|
||||
@ -25,16 +12,34 @@ class SettingsTest(TestCase):
|
||||
Tests for the 'settings' model
|
||||
"""
|
||||
|
||||
fixtures = [
|
||||
'settings',
|
||||
]
|
||||
|
||||
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.save()
|
||||
|
||||
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):
|
||||
"""
|
||||
- Ensure that every global setting has a name.
|
||||
|
@ -2,17 +2,5 @@
|
||||
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 = [
|
||||
url(r'currency/', include(currency_urls)),
|
||||
]
|
||||
|
@ -6,39 +6,15 @@ Django views for interacting with common models
|
||||
from __future__ import unicode_literals
|
||||
|
||||
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 . import models
|
||||
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):
|
||||
"""
|
||||
View for editing an InvenTree key:value settings object,
|
||||
@ -75,7 +51,11 @@ class SettingEdit(AjaxUpdateView):
|
||||
|
||||
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()
|
||||
|
||||
self.object.value = str2bool(setting.value)
|
||||
|
@ -13,7 +13,6 @@ from .models import SupplierPart
|
||||
from .models import SupplierPriceBreak
|
||||
|
||||
from part.models import Part
|
||||
from common.models import Currency
|
||||
|
||||
|
||||
class CompanyResource(ModelResource):
|
||||
@ -75,8 +74,6 @@ class SupplierPriceBreakResource(ModelResource):
|
||||
|
||||
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_name = Field(attribute='part__supplier__name', readonly=True)
|
||||
@ -98,7 +95,7 @@ class SupplierPriceBreakAdmin(ImportExportModelAdmin):
|
||||
|
||||
resource_class = SupplierPriceBreakResource
|
||||
|
||||
list_display = ('part', 'quantity', 'cost')
|
||||
list_display = ('part', 'quantity', 'price')
|
||||
|
||||
|
||||
admin.site.register(Company, CompanyAdmin)
|
||||
|
@ -7,21 +7,21 @@
|
||||
fields:
|
||||
part: 1
|
||||
quantity: 1
|
||||
cost: 10
|
||||
price: 10
|
||||
|
||||
- model: company.supplierpricebreak
|
||||
pk: 2
|
||||
fields:
|
||||
part: 1
|
||||
quantity: 5
|
||||
cost: 7.50
|
||||
price: 7.50
|
||||
|
||||
- model: company.supplierpricebreak
|
||||
pk: 3
|
||||
fields:
|
||||
part: 1
|
||||
quantity: 25
|
||||
cost: 3.50
|
||||
price: 3.50
|
||||
|
||||
# Price breaks for ACME0002
|
||||
- model: company.supplierpricebreak
|
||||
@ -29,14 +29,14 @@
|
||||
fields:
|
||||
part: 2
|
||||
quantity: 5
|
||||
cost: 7.00
|
||||
price: 7.00
|
||||
|
||||
- model: company.supplierpricebreak
|
||||
pk: 5
|
||||
fields:
|
||||
part: 2
|
||||
quantity: 50
|
||||
cost: 1.25
|
||||
price: 1.25
|
||||
|
||||
# Price breaks for ZERGLPHS
|
||||
- model: company.supplierpricebreak
|
||||
@ -44,11 +44,11 @@
|
||||
fields:
|
||||
part: 4
|
||||
quantity: 25
|
||||
cost: 8
|
||||
price: 8
|
||||
|
||||
- model: company.supplierpricebreak
|
||||
pk: 7
|
||||
fields:
|
||||
part: 4
|
||||
quantity: 100
|
||||
cost: 1.25
|
||||
price: 1.25
|
@ -8,6 +8,14 @@ from __future__ import unicode_literals
|
||||
from InvenTree.forms import HelperForm
|
||||
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 SupplierPart
|
||||
from .models import SupplierPriceBreak
|
||||
@ -24,6 +32,13 @@ class EditCompanyForm(HelperForm):
|
||||
'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:
|
||||
model = Company
|
||||
fields = [
|
||||
@ -31,6 +46,7 @@ class EditCompanyForm(HelperForm):
|
||||
'description',
|
||||
'website',
|
||||
'address',
|
||||
'currency',
|
||||
'phone',
|
||||
'email',
|
||||
'contact',
|
||||
@ -60,6 +76,15 @@ class EditSupplierPartForm(HelperForm):
|
||||
'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:
|
||||
model = SupplierPart
|
||||
fields = [
|
||||
@ -71,8 +96,9 @@ class EditSupplierPartForm(HelperForm):
|
||||
'MPN',
|
||||
'link',
|
||||
'note',
|
||||
'base_cost',
|
||||
'multiple',
|
||||
'single_pricing',
|
||||
# 'base_cost',
|
||||
# 'multiple',
|
||||
'packaging',
|
||||
]
|
||||
|
||||
@ -80,15 +106,17 @@ class EditSupplierPartForm(HelperForm):
|
||||
class EditPriceBreakForm(HelperForm):
|
||||
""" Form for creating / editing a supplier price break """
|
||||
|
||||
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5)
|
||||
|
||||
cost = RoundingDecimalFormField(max_digits=10, decimal_places=5)
|
||||
quantity = RoundingDecimalFormField(
|
||||
max_digits=10,
|
||||
decimal_places=5,
|
||||
label=_('Quantity'),
|
||||
help_text=_('Price break quantity'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = SupplierPriceBreak
|
||||
fields = [
|
||||
'part',
|
||||
'quantity',
|
||||
'cost',
|
||||
'currency',
|
||||
'price',
|
||||
]
|
||||
|
24
InvenTree/company/migrations/0025_auto_20201110_1001.py
Normal file
24
InvenTree/company/migrations/0025_auto_20201110_1001.py
Normal 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),
|
||||
),
|
||||
]
|
147
InvenTree/company/migrations/0026_auto_20201110_1011.py
Normal file
147
InvenTree/company/migrations/0026_auto_20201110_1011.py
Normal 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),
|
||||
]
|
@ -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',
|
||||
),
|
||||
]
|
@ -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',
|
||||
),
|
||||
]
|
19
InvenTree/company/migrations/0029_company_currency.py
Normal file
19
InvenTree/company/migrations/0029_company_currency.py
Normal 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'),
|
||||
),
|
||||
]
|
20
InvenTree/company/migrations/0030_auto_20201112_1112.py
Normal file
20
InvenTree/company/migrations/0030_auto_20201112_1112.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -17,6 +17,8 @@ from django.db.models import Sum, Q, UniqueConstraint
|
||||
from django.apps import apps
|
||||
from django.urls import reverse
|
||||
|
||||
from moneyed import CURRENCIES
|
||||
|
||||
from markdownx.models import MarkdownxField
|
||||
|
||||
from stdimage.models import StdImageField
|
||||
@ -26,7 +28,10 @@ from InvenTree.helpers import normalize
|
||||
from InvenTree.fields import InvenTreeURLField
|
||||
from InvenTree.status_codes import PurchaseOrderStatus
|
||||
|
||||
import InvenTree.validators
|
||||
|
||||
import common.models
|
||||
import common.settings
|
||||
|
||||
|
||||
def rename_company_image(instance, filename):
|
||||
@ -77,6 +82,7 @@ class Company(models.Model):
|
||||
is_customer: boolean value, is this company a customer
|
||||
is_supplier: boolean value, is this company a supplier
|
||||
is_manufacturer: boolean value, is this company a manufacturer
|
||||
currency_code: Specifies the default currency for the company
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
@ -126,6 +132,30 @@ class Company(models.Model):
|
||||
|
||||
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):
|
||||
""" Get string representation of a Company """
|
||||
return "{n} - {d}".format(n=self.name, d=self.description)
|
||||
@ -350,7 +380,26 @@ class SupplierPart(models.Model):
|
||||
def unit_pricing(self):
|
||||
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.
|
||||
|
||||
- Don't forget to add in flat-fee cost (base_cost field)
|
||||
@ -372,6 +421,10 @@ class SupplierPart(models.Model):
|
||||
pb_quantity = -1
|
||||
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():
|
||||
# Ignore this pricebreak (quantity is too high)
|
||||
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 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:
|
||||
cost = pb_cost * quantity
|
||||
@ -462,7 +516,4 @@ class SupplierPriceBreak(common.models.PriceBreak):
|
||||
db_table = 'part_supplierpricebreak'
|
||||
|
||||
def __str__(self):
|
||||
return "{mpn} - {cost} @ {quan}".format(
|
||||
mpn=self.part.MPN,
|
||||
cost=self.cost,
|
||||
quan=self.quantity)
|
||||
return f'{self.part.MPN} - {self.price} @ {self.quantity}'
|
||||
|
@ -137,13 +137,9 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
||||
class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
|
||||
""" Serializer for SupplierPriceBreak object """
|
||||
|
||||
symbol = serializers.CharField(read_only=True)
|
||||
|
||||
suffix = serializers.CharField(read_only=True)
|
||||
|
||||
quantity = serializers.FloatField()
|
||||
|
||||
cost = serializers.FloatField()
|
||||
price = serializers.CharField()
|
||||
|
||||
class Meta:
|
||||
model = SupplierPriceBreak
|
||||
@ -151,8 +147,5 @@ class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
|
||||
'pk',
|
||||
'part',
|
||||
'quantity',
|
||||
'cost',
|
||||
'currency',
|
||||
'symbol',
|
||||
'suffix',
|
||||
'price',
|
||||
]
|
||||
|
@ -8,25 +8,64 @@
|
||||
<h4>{% trans "Company Details" %}</h4>
|
||||
<hr>
|
||||
|
||||
<table class='table table-striped'>
|
||||
<col width='25'>
|
||||
<col>
|
||||
<tr>
|
||||
<td><span class='fas fa-industry'></span></td>
|
||||
<td>{% trans "Manufacturer" %}</td>
|
||||
<td>{% include "yesnolabel.html" with value=company.is_manufacturer %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class='fas fa-building'></span></td>
|
||||
<td>{% trans "Supplier" %}</td>
|
||||
<td>{% include 'yesnolabel.html' with value=company.is_supplier %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class='fas fa-user-tie'></span></td>
|
||||
<td>{% trans "Customer" %}</td>
|
||||
<td>{% include 'yesnolabel.html' with value=company.is_customer %}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<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>
|
||||
<tr>
|
||||
<td><span class='fas fa-industry'></span></td>
|
||||
<td>{% trans "Manufacturer" %}</td>
|
||||
<td>{% include "yesnolabel.html" with value=company.is_manufacturer %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class='fas fa-building'></span></td>
|
||||
<td>{% trans "Supplier" %}</td>
|
||||
<td>{% include 'yesnolabel.html' with value=company.is_supplier %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class='fas fa-user-tie'></span></td>
|
||||
<td>{% trans "Customer" %}</td>
|
||||
<td>{% include 'yesnolabel.html' with value=company.is_customer %}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
{% block js_ready %}
|
||||
|
@ -76,18 +76,11 @@ $('#price-break-table').inventreeTable({
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
field: 'cost',
|
||||
field: 'price',
|
||||
title: '{% trans "Price" %}',
|
||||
sortable: true,
|
||||
formatter: function(value, row, index) {
|
||||
var html = '';
|
||||
|
||||
html += row.symbol || '';
|
||||
html += value;
|
||||
|
||||
if (row.suffix) {
|
||||
html += ' ' + row.suffix || '';
|
||||
}
|
||||
var html = value;
|
||||
|
||||
html += `<div class='btn-group float-right' role='group'>`
|
||||
|
||||
|
@ -15,8 +15,8 @@ class CompanyTest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
# Create a user for auth
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user('testuser', 'test@testing.com', 'password')
|
||||
user = get_user_model()
|
||||
self.user = user.objects.create_user('testuser', 'test@testing.com', 'password')
|
||||
|
||||
perms = [
|
||||
'view_company',
|
||||
|
@ -3,6 +3,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import json
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.contrib.auth import get_user_model
|
||||
@ -11,7 +13,7 @@ from django.contrib.auth.models import Group
|
||||
from .models import SupplierPart
|
||||
|
||||
|
||||
class CompanyViewTest(TestCase):
|
||||
class CompanyViewTestBase(TestCase):
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
@ -25,8 +27,9 @@ class CompanyViewTest(TestCase):
|
||||
super().setUp()
|
||||
|
||||
# Create a user
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(
|
||||
user = get_user_model()
|
||||
|
||||
self.user = user.objects.create_user(
|
||||
username='username',
|
||||
email='user@email.com',
|
||||
password='password'
|
||||
@ -47,14 +50,104 @@ class CompanyViewTest(TestCase):
|
||||
|
||||
self.client.login(username='username', password='password')
|
||||
|
||||
def test_company_index(self):
|
||||
""" Test the company index """
|
||||
def post(self, url, data, valid=None):
|
||||
"""
|
||||
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)
|
||||
|
||||
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):
|
||||
""" Test the SupplierPartDelete view """
|
||||
"""
|
||||
Test the SupplierPartDelete view
|
||||
"""
|
||||
|
||||
url = reverse('supplier-part-delete')
|
||||
|
||||
@ -80,3 +173,30 @@ class CompanyViewTest(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
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')
|
||||
|
@ -1,4 +1,5 @@
|
||||
from django.test import TestCase
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
import os
|
||||
|
||||
@ -6,6 +7,9 @@ from .models import Company, Contact, SupplierPart
|
||||
from .models import rename_company_image
|
||||
from part.models import Part
|
||||
|
||||
from InvenTree.exchange import InvenTreeManualExchangeBackend
|
||||
from djmoney.contrib.exchange.models import Rate
|
||||
|
||||
|
||||
class CompanySimpleTest(TestCase):
|
||||
|
||||
@ -32,6 +36,14 @@ class CompanySimpleTest(TestCase):
|
||||
self.zerglphs = SupplierPart.objects.get(SKU='ZERGLPHS')
|
||||
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):
|
||||
c = Company.objects.get(name='ABC Co.')
|
||||
self.assertEqual(c.name, 'ABC Co.')
|
||||
@ -108,6 +120,30 @@ class CompanySimpleTest(TestCase):
|
||||
self.assertIsNone(m3x12.get_price_info(3))
|
||||
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):
|
||||
|
||||
|
@ -12,12 +12,12 @@ from django.views.generic import DetailView, ListView, UpdateView
|
||||
from django.urls import reverse
|
||||
from django.forms import HiddenInput
|
||||
|
||||
from moneyed import CURRENCIES
|
||||
|
||||
from InvenTree.views import AjaxCreateView, AjaxUpdateView, AjaxDeleteView
|
||||
from InvenTree.helpers import str2bool
|
||||
from InvenTree.views import InvenTreeRoleMixin
|
||||
|
||||
from common.models import Currency
|
||||
|
||||
from .models import Company
|
||||
from .models import SupplierPart
|
||||
from .models import SupplierPriceBreak
|
||||
@ -29,6 +29,9 @@ from .forms import CompanyImageForm
|
||||
from .forms import EditSupplierPartForm
|
||||
from .forms import EditPriceBreakForm
|
||||
|
||||
import common.models
|
||||
import common.settings
|
||||
|
||||
|
||||
class CompanyIndex(InvenTreeRoleMixin, ListView):
|
||||
""" View for displaying list of companies
|
||||
@ -268,6 +271,14 @@ class SupplierPartEdit(AjaxUpdateView):
|
||||
ajax_form_title = _('Edit Supplier Part')
|
||||
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):
|
||||
""" Create view for making new SupplierPart """
|
||||
@ -279,6 +290,30 @@ class SupplierPartCreate(AjaxCreateView):
|
||||
context_object_name = 'part'
|
||||
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):
|
||||
""" Create Form instance to create a new SupplierPart object.
|
||||
Hide some fields if they are not appropriate in context
|
||||
@ -303,11 +338,14 @@ class SupplierPartCreate(AjaxCreateView):
|
||||
supplier_id = self.get_param('supplier')
|
||||
part_id = self.get_param('part')
|
||||
|
||||
supplier = None
|
||||
|
||||
if supplier_id:
|
||||
try:
|
||||
initials['supplier'] = Company.objects.get(pk=supplier_id)
|
||||
supplier = Company.objects.get(pk=supplier_id)
|
||||
initials['supplier'] = supplier
|
||||
except (ValueError, Company.DoesNotExist):
|
||||
pass
|
||||
supplier = None
|
||||
|
||||
if manufacturer_id:
|
||||
try:
|
||||
@ -321,6 +359,17 @@ class SupplierPartCreate(AjaxCreateView):
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
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
|
||||
|
||||
|
||||
@ -417,10 +466,23 @@ class PriceBreakCreate(AjaxCreateView):
|
||||
}
|
||||
|
||||
def get_part(self):
|
||||
"""
|
||||
Attempt to extract SupplierPart object from the supplied data.
|
||||
"""
|
||||
|
||||
try:
|
||||
return SupplierPart.objects.get(id=self.request.GET.get('part'))
|
||||
except SupplierPart.DoesNotExist:
|
||||
return SupplierPart.objects.get(id=self.request.POST.get('part'))
|
||||
supplier_part = SupplierPart.objects.get(pk=self.request.GET.get('part'))
|
||||
return supplier_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):
|
||||
|
||||
@ -433,14 +495,20 @@ class PriceBreakCreate(AjaxCreateView):
|
||||
|
||||
initials = super(AjaxCreateView, self).get_initial()
|
||||
|
||||
supplier_part = self.get_part()
|
||||
|
||||
initials['part'] = self.get_part()
|
||||
|
||||
# Pre-select the default currency
|
||||
try:
|
||||
base = Currency.objects.get(base=True)
|
||||
initials['currency'] = base
|
||||
except Currency.DoesNotExist:
|
||||
pass
|
||||
if supplier_part is not None:
|
||||
currency_code = supplier_part.supplier.currency_code
|
||||
else:
|
||||
currency_code = common.settings.currency_code_default()
|
||||
|
||||
# Extract the currency object associated with the code
|
||||
currency = CURRENCIES.get(currency_code, None)
|
||||
|
||||
if currency:
|
||||
initials['price'] = [1.0, currency]
|
||||
|
||||
return initials
|
||||
|
||||
|
@ -26,6 +26,17 @@ language: en-us
|
||||
# Select an option from the "TZ database name" column
|
||||
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
|
||||
debug: True
|
||||
|
||||
|
Binary file not shown.
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
@ -175,6 +175,7 @@ class EditPurchaseOrderLineItemForm(HelperForm):
|
||||
'part',
|
||||
'quantity',
|
||||
'reference',
|
||||
'purchase_price',
|
||||
'notes',
|
||||
]
|
||||
|
||||
|
20
InvenTree/order/migrations/0037_auto_20201110_0911.py
Normal file
20
InvenTree/order/migrations/0037_auto_20201110_0911.py
Normal 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'),
|
||||
),
|
||||
]
|
24
InvenTree/order/migrations/0038_auto_20201112_1737.py
Normal file
24
InvenTree/order/migrations/0038_auto_20201112_1737.py
Normal 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),
|
||||
),
|
||||
]
|
19
InvenTree/order/migrations/0039_auto_20201112_2203.py
Normal file
19
InvenTree/order/migrations/0039_auto_20201112_2203.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -4,6 +4,10 @@ Order model definitions
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import models, transaction
|
||||
from django.db.models import F, Sum
|
||||
from django.db.models.functions import Coalesce
|
||||
@ -15,9 +19,7 @@ from django.utils.translation import ugettext as _
|
||||
|
||||
from markdownx.models import MarkdownxField
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from djmoney.models.fields import MoneyField
|
||||
|
||||
from part import models as PartModels
|
||||
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'))
|
||||
|
||||
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):
|
||||
""" Calculate the number of items remaining to be received """
|
||||
r = self.quantity - self.received
|
||||
@ -621,7 +632,6 @@ class SalesOrderAllocation(models.Model):
|
||||
'part__salable': True,
|
||||
'belongs_to': None,
|
||||
'sales_order': None,
|
||||
'build_order': None,
|
||||
},
|
||||
help_text=_('Select stock item to allocate')
|
||||
)
|
||||
|
@ -95,6 +95,8 @@ class POLineItemSerializer(InvenTreeModelSerializer):
|
||||
part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True)
|
||||
supplier_part_detail = SupplierPartSerializer(source='part', many=False, read_only=True)
|
||||
|
||||
purchase_price_string = serializers.CharField(source='purchase_price', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrderLineItem
|
||||
|
||||
@ -108,6 +110,9 @@ class POLineItemSerializer(InvenTreeModelSerializer):
|
||||
'part_detail',
|
||||
'supplier_part_detail',
|
||||
'received',
|
||||
'purchase_price',
|
||||
'purchase_price_currency',
|
||||
'purchase_price_string',
|
||||
]
|
||||
|
||||
|
||||
|
@ -146,6 +146,7 @@ $("#po-table").inventreeTable({
|
||||
field: 'part',
|
||||
sortable: true,
|
||||
title: '{% trans "Part" %}',
|
||||
switchable: false,
|
||||
formatter: function(value, row, index, field) {
|
||||
if (row.part) {
|
||||
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',
|
||||
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,
|
||||
field: 'received',
|
||||
switchable: false,
|
||||
title: '{% trans "Received" %}',
|
||||
formatter: function(value, row, index, field) {
|
||||
return makeProgressBar(row.received, row.quantity, {
|
||||
@ -203,6 +213,7 @@ $("#po-table").inventreeTable({
|
||||
title: '{% trans "Notes" %}',
|
||||
},
|
||||
{
|
||||
switchable: false,
|
||||
field: 'buttons',
|
||||
title: '',
|
||||
formatter: function(value, row, index, field) {
|
||||
|
@ -23,8 +23,7 @@ class OrderTest(APITestCase):
|
||||
def setUp(self):
|
||||
|
||||
# Create a user for auth
|
||||
User = get_user_model()
|
||||
User.objects.create_user('testuser', 'test@testing.com', 'password')
|
||||
get_user_model().objects.create_user('testuser', 'test@testing.com', 'password')
|
||||
self.client.login(username='testuser', password='password')
|
||||
|
||||
def doGet(self, url, options=''):
|
||||
|
@ -120,12 +120,12 @@ class SalesOrderTest(TestCase):
|
||||
# There should now be 4 stock items
|
||||
self.assertEqual(StockItem.objects.count(), 4)
|
||||
|
||||
Sa = StockItem.objects.get(pk=self.Sa.pk)
|
||||
Sb = StockItem.objects.get(pk=self.Sb.pk)
|
||||
sa = StockItem.objects.get(pk=self.Sa.pk)
|
||||
sb = StockItem.objects.get(pk=self.Sb.pk)
|
||||
|
||||
# 25 units subtracted from each of the original items
|
||||
self.assertEqual(Sa.quantity, 75)
|
||||
self.assertEqual(Sb.quantity, 175)
|
||||
self.assertEqual(sa.quantity, 75)
|
||||
self.assertEqual(sb.quantity, 175)
|
||||
|
||||
# And 2 items created which are associated with the order
|
||||
outputs = StockItem.objects.filter(sales_order=self.order)
|
||||
@ -134,8 +134,8 @@ class SalesOrderTest(TestCase):
|
||||
for item in outputs.all():
|
||||
self.assertEqual(item.quantity, 25)
|
||||
|
||||
self.assertEqual(Sa.sales_order, None)
|
||||
self.assertEqual(Sb.sales_order, None)
|
||||
self.assertEqual(sa.sales_order, None)
|
||||
self.assertEqual(sb.sales_order, None)
|
||||
|
||||
# And no allocations
|
||||
self.assertEqual(SalesOrderAllocation.objects.count(), 0)
|
||||
|
@ -32,8 +32,7 @@ class OrderViewTestCase(TestCase):
|
||||
super().setUp()
|
||||
|
||||
# Create a user
|
||||
User = get_user_model()
|
||||
user = User.objects.create_user('username', 'user@email.com', 'password')
|
||||
user = get_user_model().objects.create_user('username', 'user@email.com', 'password')
|
||||
|
||||
# Ensure that the user has the correct permissions!
|
||||
g = Group.objects.create(name='orders')
|
||||
|
@ -12,6 +12,7 @@ from .models import PartCategory, Part
|
||||
from .models import PartAttachment, PartStar, PartRelated
|
||||
from .models import BomItem
|
||||
from .models import PartParameterTemplate, PartParameter
|
||||
from .models import PartCategoryParameterTemplate
|
||||
from .models import PartTestTemplate
|
||||
from .models import PartSellPriceBreak
|
||||
|
||||
@ -274,12 +275,17 @@ class ParameterAdmin(ImportExportModelAdmin):
|
||||
list_display = ('part', 'template', 'data')
|
||||
|
||||
|
||||
class PartCategoryParameterAdmin(admin.ModelAdmin):
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class PartSellPriceBreakAdmin(admin.ModelAdmin):
|
||||
|
||||
class Meta:
|
||||
model = PartSellPriceBreak
|
||||
|
||||
list_display = ('part', 'quantity', 'cost', 'currency')
|
||||
list_display = ('part', 'quantity', 'price',)
|
||||
|
||||
|
||||
admin.site.register(Part, PartAdmin)
|
||||
@ -290,5 +296,6 @@ admin.site.register(PartStar, PartStarAdmin)
|
||||
admin.site.register(BomItem, BomItemAdmin)
|
||||
admin.site.register(PartParameterTemplate, ParameterTemplateAdmin)
|
||||
admin.site.register(PartParameter, ParameterAdmin)
|
||||
admin.site.register(PartCategoryParameterTemplate, PartCategoryParameterAdmin)
|
||||
admin.site.register(PartTestTemplate, PartTestTemplateAdmin)
|
||||
admin.site.register(PartSellPriceBreak, PartSellPriceBreakAdmin)
|
||||
|
@ -21,6 +21,7 @@ from .models import Part, PartCategory, BomItem, PartStar
|
||||
from .models import PartParameter, PartParameterTemplate
|
||||
from .models import PartAttachment, PartTestTemplate
|
||||
from .models import PartSellPriceBreak
|
||||
from .models import PartCategoryParameterTemplate
|
||||
|
||||
from build.models import Build
|
||||
|
||||
@ -111,6 +112,36 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
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):
|
||||
"""
|
||||
API endpoint for list view of PartSalePriceBreak model
|
||||
@ -864,6 +895,7 @@ part_api_urls = [
|
||||
|
||||
# Base URL for PartCategory API endpoints
|
||||
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'^$', CategoryList.as_view(), name='api-part-category-list'),
|
||||
])),
|
||||
|
@ -18,7 +18,7 @@
|
||||
name: Thickness
|
||||
units: mm
|
||||
|
||||
# And some parameters (requires part.yaml)
|
||||
# Add some parameters to parts (requires part.yaml)
|
||||
- model: part.PartParameter
|
||||
pk: 1
|
||||
fields:
|
||||
@ -32,3 +32,18 @@
|
||||
part: 2
|
||||
template: 1
|
||||
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'
|
||||
|
@ -8,6 +8,7 @@
|
||||
category: 8
|
||||
link: www.acme.com/parts/m2x4lphs
|
||||
tree_id: 0
|
||||
purchaseable: True
|
||||
level: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
@ -16,11 +16,10 @@ from django.utils.translation import ugettext as _
|
||||
from .models import Part, PartCategory, PartAttachment, PartRelated
|
||||
from .models import BomItem
|
||||
from .models import PartParameterTemplate, PartParameter
|
||||
from .models import PartCategoryParameterTemplate
|
||||
from .models import PartTestTemplate
|
||||
from .models import PartSellPriceBreak
|
||||
|
||||
from common.models import Currency
|
||||
|
||||
|
||||
class PartModelChoiceField(forms.ModelChoiceField):
|
||||
""" Extending string representation of Part instance with available stock """
|
||||
@ -201,10 +200,22 @@ class EditPartForm(HelperForm):
|
||||
help_text=_('Confirm part creation'),
|
||||
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:
|
||||
model = Part
|
||||
fields = [
|
||||
'category',
|
||||
'selected_category_templates',
|
||||
'parent_category_templates',
|
||||
'name',
|
||||
'IPN',
|
||||
'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):
|
||||
""" Form for editing a BomItem object """
|
||||
|
||||
@ -298,13 +331,10 @@ class PartPriceForm(forms.Form):
|
||||
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:
|
||||
model = Part
|
||||
fields = [
|
||||
'quantity',
|
||||
'currency',
|
||||
]
|
||||
|
||||
|
||||
@ -315,13 +345,10 @@ class EditPartSalePriceBreakForm(HelperForm):
|
||||
|
||||
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5)
|
||||
|
||||
cost = RoundingDecimalFormField(max_digits=10, decimal_places=5)
|
||||
|
||||
class Meta:
|
||||
model = PartSellPriceBreak
|
||||
fields = [
|
||||
'part',
|
||||
'quantity',
|
||||
'cost',
|
||||
'currency',
|
||||
'price',
|
||||
]
|
||||
|
@ -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),
|
||||
),
|
||||
]
|
@ -2,7 +2,7 @@
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
import InvenTree.fields
|
||||
|
||||
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')),
|
||||
],
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='part',
|
||||
name='link',
|
||||
field=InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True),
|
||||
),
|
||||
]
|
||||
|
@ -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 = [
|
||||
]
|
@ -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'),
|
||||
),
|
||||
]
|
@ -7,7 +7,7 @@ import part.settings
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('part', '0053_merge_20201103_1028'),
|
||||
('part', '0052_partrelated'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
|
24
InvenTree/part/migrations/0055_auto_20201110_1001.py
Normal file
24
InvenTree/part/migrations/0055_auto_20201110_1001.py
Normal 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),
|
||||
),
|
||||
]
|
147
InvenTree/part/migrations/0056_auto_20201110_1125.py
Normal file
147
InvenTree/part/migrations/0056_auto_20201110_1125.py
Normal 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),
|
||||
]
|
@ -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',
|
||||
),
|
||||
]
|
@ -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',
|
||||
),
|
||||
]
|
20
InvenTree/part/migrations/0059_auto_20201112_1112.py
Normal file
20
InvenTree/part/migrations/0059_auto_20201112_1112.py
Normal 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'),
|
||||
),
|
||||
]
|
14
InvenTree/part/migrations/0060_merge_20201112_1722.py
Normal file
14
InvenTree/part/migrations/0060_merge_20201112_1722.py
Normal 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 = [
|
||||
]
|
@ -12,7 +12,8 @@ from django.core.exceptions import ValidationError
|
||||
from django.urls import reverse
|
||||
|
||||
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.core.validators import MinValueValidator
|
||||
|
||||
@ -164,6 +165,26 @@ class PartCategory(InvenTreeTree):
|
||||
|
||||
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')
|
||||
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.
|
||||
"""
|
||||
|
||||
# Get category templates settings
|
||||
add_category_templates = kwargs.pop('add_category_templates', None)
|
||||
|
||||
if self.pk:
|
||||
previous = Part.objects.get(pk=self.pk)
|
||||
|
||||
@ -322,6 +346,44 @@ class Part(MPTTModel):
|
||||
|
||||
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):
|
||||
return f"{self.full_name} - {self.description}"
|
||||
|
||||
@ -1664,6 +1726,49 @@ class PartParameter(models.Model):
|
||||
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):
|
||||
""" A BomItem links a part to its component items.
|
||||
A part can have a BOM (bill of materials) which defines
|
||||
|
@ -15,7 +15,7 @@ from stock.models import StockItem
|
||||
|
||||
from .models import (BomItem, Part, PartAttachment, PartCategory,
|
||||
PartParameter, PartParameterTemplate, PartSellPriceBreak,
|
||||
PartStar, PartTestTemplate)
|
||||
PartStar, PartTestTemplate, PartCategoryParameterTemplate)
|
||||
|
||||
|
||||
class CategorySerializer(InvenTreeModelSerializer):
|
||||
@ -84,13 +84,9 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
|
||||
Serializer for sale prices for Part model.
|
||||
"""
|
||||
|
||||
symbol = serializers.CharField(read_only=True)
|
||||
|
||||
suffix = serializers.CharField(read_only=True)
|
||||
|
||||
quantity = serializers.FloatField()
|
||||
|
||||
cost = serializers.FloatField()
|
||||
price = serializers.CharField()
|
||||
|
||||
class Meta:
|
||||
model = PartSellPriceBreak
|
||||
@ -98,10 +94,7 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
|
||||
'pk',
|
||||
'part',
|
||||
'quantity',
|
||||
'cost',
|
||||
'currency',
|
||||
'symbol',
|
||||
'suffix',
|
||||
'price',
|
||||
]
|
||||
|
||||
|
||||
@ -425,3 +418,21 @@ class PartParameterTemplateSerializer(InvenTreeModelSerializer):
|
||||
'name',
|
||||
'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',
|
||||
]
|
||||
|
@ -10,7 +10,9 @@
|
||||
<hr>
|
||||
|
||||
<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>
|
||||
|
||||
<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,
|
||||
},
|
||||
{
|
||||
field: 'cost',
|
||||
field: 'price',
|
||||
title: '{% trans "Price" %}',
|
||||
sortable: true,
|
||||
formatter: function(value, row, index) {
|
||||
var html = '';
|
||||
|
||||
html += row.symbol || '';
|
||||
html += value;
|
||||
|
||||
if (row.suffix) {
|
||||
html += ' ' + row.suffix || '';
|
||||
}
|
||||
var html = value;
|
||||
|
||||
html += `<div class='btn-group float-right' role='group'>`
|
||||
|
||||
|
@ -29,8 +29,9 @@ class PartAPITest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
# Create a user for auth
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(
|
||||
user = get_user_model()
|
||||
|
||||
self.user = user.objects.create_user(
|
||||
username='testuser',
|
||||
email='test@testing.com',
|
||||
password='password'
|
||||
@ -269,8 +270,9 @@ class PartAPIAggregationTest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
# Create a user for auth
|
||||
User = get_user_model()
|
||||
User.objects.create_user('testuser', 'test@testing.com', 'password')
|
||||
user = get_user_model()
|
||||
|
||||
user.objects.create_user('testuser', 'test@testing.com', 'password')
|
||||
|
||||
self.client.login(username='testuser', password='password')
|
||||
|
||||
|
@ -165,14 +165,14 @@ class CategoryTest(TestCase):
|
||||
self.assert_equal(p.get_default_location().pathstring, 'Office/Drawer_1')
|
||||
|
||||
# Any part under electronics should default to 'Home'
|
||||
R1 = Part.objects.get(name='R_2K2_0805')
|
||||
self.assertIsNone(R1.default_location)
|
||||
self.assertEqual(R1.get_default_location().name, 'Home')
|
||||
r1 = Part.objects.get(name='R_2K2_0805')
|
||||
self.assertIsNone(r1.default_location)
|
||||
self.assertEqual(r1.get_default_location().name, 'Home')
|
||||
|
||||
# But one part has a default_location set
|
||||
R2 = Part.objects.get(name='R_4K7_0603')
|
||||
self.assertEqual(R2.get_default_location().name, 'Bathroom')
|
||||
r2 = Part.objects.get(name='R_4K7_0603')
|
||||
self.assertEqual(r2.get_default_location().name, 'Bathroom')
|
||||
|
||||
# And one part should have no default location at all
|
||||
W = Part.objects.get(name='Widget')
|
||||
self.assertIsNone(W.get_default_location())
|
||||
w = Part.objects.get(name='Widget')
|
||||
self.assertIsNone(w.get_default_location())
|
||||
|
@ -3,10 +3,12 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.test import TestCase
|
||||
from django.test import TestCase, TransactionTestCase
|
||||
import django.core.exceptions as django_exceptions
|
||||
|
||||
from .models import Part, PartCategory
|
||||
from .models import PartParameter, PartParameterTemplate
|
||||
from .models import PartCategoryParameterTemplate
|
||||
|
||||
|
||||
class TestParams(TestCase):
|
||||
@ -24,7 +26,10 @@ class TestParams(TestCase):
|
||||
self.assertEquals(str(t1), 'Length (mm)')
|
||||
|
||||
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):
|
||||
|
||||
@ -40,3 +45,47 @@ class TestParams(TestCase):
|
||||
t3 = PartParameterTemplate(name='aBcde', units='dd')
|
||||
t3.full_clean()
|
||||
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())
|
||||
|
@ -54,10 +54,10 @@ class PartTest(TestCase):
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
self.R1 = Part.objects.get(name='R_2K2_0805')
|
||||
self.R2 = Part.objects.get(name='R_4K7_0603')
|
||||
self.r1 = Part.objects.get(name='R_2K2_0805')
|
||||
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()
|
||||
|
||||
@ -78,18 +78,18 @@ class PartTest(TestCase):
|
||||
self.assertEqual(str(p), "BOB | Bob | A2 - Can we build it?")
|
||||
|
||||
def test_metadata(self):
|
||||
self.assertEqual(self.R1.name, 'R_2K2_0805')
|
||||
self.assertEqual(self.R1.get_absolute_url(), '/part/3/')
|
||||
self.assertEqual(self.r1.name, 'R_2K2_0805')
|
||||
self.assertEqual(self.r1.get_absolute_url(), '/part/3/')
|
||||
|
||||
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')
|
||||
self.assertIsNone(orphan.category)
|
||||
self.assertEqual(orphan.category_path, '')
|
||||
|
||||
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'))
|
||||
|
||||
def test_stock(self):
|
||||
@ -100,12 +100,12 @@ class PartTest(TestCase):
|
||||
self.assertEqual(r.available_stock, 0)
|
||||
|
||||
def test_barcode(self):
|
||||
barcode = self.R1.format_barcode()
|
||||
barcode = self.r1.format_barcode()
|
||||
self.assertIn('InvenTree', barcode)
|
||||
self.assertIn(self.R1.name, barcode)
|
||||
self.assertIn(self.r1.name, barcode)
|
||||
|
||||
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):
|
||||
|
||||
@ -181,9 +181,9 @@ class PartSettingsTest(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
# 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',
|
||||
email='test@testing.com',
|
||||
password='password',
|
||||
|
@ -23,8 +23,9 @@ class PartViewTestCase(TestCase):
|
||||
super().setUp()
|
||||
|
||||
# Create a user
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(
|
||||
user = get_user_model()
|
||||
|
||||
self.user = user.objects.create_user(
|
||||
username='username',
|
||||
email='user@email.com',
|
||||
password='password'
|
||||
|
@ -80,10 +80,18 @@ part_detail_urls = [
|
||||
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 = [
|
||||
url(r'^edit/?', views.CategoryEdit.as_view(), name='category-edit'),
|
||||
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'^.*$', views.CategoryDetail.as_view(), name='category-detail'),
|
||||
]
|
||||
|
@ -7,6 +7,7 @@ from __future__ import unicode_literals
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.db.utils import IntegrityError
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.shortcuts import HttpResponseRedirect
|
||||
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.conf import settings
|
||||
|
||||
from moneyed import CURRENCIES
|
||||
|
||||
import os
|
||||
|
||||
from rapidfuzz import fuzz
|
||||
@ -23,12 +26,13 @@ from decimal import Decimal, InvalidOperation
|
||||
|
||||
from .models import PartCategory, Part, PartAttachment, PartRelated
|
||||
from .models import PartParameterTemplate, PartParameter
|
||||
from .models import PartCategoryParameterTemplate
|
||||
from .models import BomItem
|
||||
from .models import match_part_names
|
||||
from .models import PartTestTemplate
|
||||
from .models import PartSellPriceBreak
|
||||
|
||||
from common.models import Currency, InvenTreeSetting
|
||||
from common.models import InvenTreeSetting
|
||||
from company.models import SupplierPart
|
||||
|
||||
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!)
|
||||
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
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
@ -667,7 +675,14 @@ class PartCreate(AjaxCreateView):
|
||||
# Record the user who created this part
|
||||
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['text'] = str(part)
|
||||
@ -700,6 +715,10 @@ class PartCreate(AjaxCreateView):
|
||||
if label in self.request.GET:
|
||||
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
|
||||
|
||||
|
||||
@ -1860,19 +1879,12 @@ class PartPricing(AjaxView):
|
||||
if quantity < 1:
|
||||
quantity = 1
|
||||
|
||||
if currency is None:
|
||||
# No currency selected? Try to select a default one
|
||||
try:
|
||||
currency = Currency.objects.get(base=1)
|
||||
except Currency.DoesNotExist:
|
||||
currency = None
|
||||
# TODO - Capacity for price comparison in different currencies
|
||||
currency = None
|
||||
|
||||
# Currency scaler
|
||||
scaler = Decimal(1.0)
|
||||
|
||||
if currency is not None:
|
||||
scaler = Decimal(currency.value)
|
||||
|
||||
part = self.get_part()
|
||||
|
||||
ctx = {
|
||||
@ -1942,13 +1954,8 @@ class PartPricing(AjaxView):
|
||||
except ValueError:
|
||||
quantity = 1
|
||||
|
||||
try:
|
||||
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
|
||||
# TODO - How to handle pricing in different currencies?
|
||||
currency = None
|
||||
|
||||
# Always mark the form as 'invalid' (the user may wish to keep getting pricing data)
|
||||
data = {
|
||||
@ -2215,6 +2222,185 @@ class CategoryCreate(AjaxCreateView):
|
||||
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):
|
||||
""" Detail view for BomItem """
|
||||
context_object_name = 'item'
|
||||
@ -2393,12 +2579,11 @@ class PartSalePriceBreakCreate(AjaxCreateView):
|
||||
|
||||
initials['part'] = self.get_part()
|
||||
|
||||
# Pre-select the default currency
|
||||
try:
|
||||
base = Currency.objects.get(base=True)
|
||||
initials['currency'] = base
|
||||
except Currency.DoesNotExist:
|
||||
pass
|
||||
default_currency = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY')
|
||||
currency = CURRENCIES.get(default_currency, None)
|
||||
|
||||
if currency is not None:
|
||||
initials['price'] = [1.0, currency]
|
||||
|
||||
return initials
|
||||
|
||||
|
@ -83,8 +83,6 @@ class StockItemResource(ModelResource):
|
||||
|
||||
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))
|
||||
|
||||
# Date management
|
||||
|
@ -488,11 +488,7 @@ class StockList(generics.ListCreateAPIView):
|
||||
if build:
|
||||
queryset = queryset.filter(build=build)
|
||||
|
||||
build_order = params.get('build_order', None)
|
||||
|
||||
if build_order:
|
||||
queryset = queryset.filter(build_order=build_order)
|
||||
|
||||
# Filter by 'is building' status
|
||||
is_building = params.get('is_building', None)
|
||||
|
||||
if is_building:
|
||||
@ -621,10 +617,10 @@ class StockList(generics.ListCreateAPIView):
|
||||
queryset = queryset.exclude(quantity__lte=0)
|
||||
|
||||
# Filter by internal part number
|
||||
IPN = params.get('IPN', None)
|
||||
ipn = params.get('IPN', None)
|
||||
|
||||
if IPN is not None:
|
||||
queryset = queryset.filter(part__IPN=IPN)
|
||||
if ipn is not None:
|
||||
queryset = queryset.filter(part__IPN=ipn)
|
||||
|
||||
# Does the client wish to filter by the Part ID?
|
||||
part_id = params.get('part', None)
|
||||
|
@ -124,6 +124,7 @@ class CreateStockItemForm(HelperForm):
|
||||
fields = [
|
||||
'part',
|
||||
'supplier_part',
|
||||
'purchase_price',
|
||||
'location',
|
||||
'quantity',
|
||||
'batch',
|
||||
@ -399,6 +400,7 @@ class EditStockItemForm(HelperForm):
|
||||
'serial',
|
||||
'batch',
|
||||
'status',
|
||||
'purchase_price',
|
||||
'link',
|
||||
'delete_on_deplete',
|
||||
]
|
||||
|
24
InvenTree/stock/migrations/0053_auto_20201110_0513.py
Normal file
24
InvenTree/stock/migrations/0053_auto_20201110_0513.py
Normal file
File diff suppressed because one or more lines are too long
@ -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',
|
||||
),
|
||||
]
|
@ -24,6 +24,8 @@ from markdownx.models import MarkdownxField
|
||||
|
||||
from mptt.models import MPTTModel, TreeForeignKey
|
||||
|
||||
from djmoney.models.fields import MoneyField
|
||||
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from datetime import datetime
|
||||
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)
|
||||
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)
|
||||
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"
|
||||
IN_STOCK_FILTER = Q(
|
||||
quantity__gt=0,
|
||||
sales_order=None,
|
||||
build_order=None,
|
||||
belongs_to=None,
|
||||
customer=None,
|
||||
is_building=False,
|
||||
@ -427,14 +428,6 @@ class StockItem(MPTTModel):
|
||||
related_name='stock_items',
|
||||
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
|
||||
stocktake_date = models.DateField(blank=True, null=True)
|
||||
|
||||
@ -456,6 +449,15 @@ class StockItem(MPTTModel):
|
||||
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):
|
||||
"""
|
||||
Clear all order allocations for this StockItem:
|
||||
@ -602,9 +604,6 @@ class StockItem(MPTTModel):
|
||||
if self.sales_order is not None:
|
||||
return False
|
||||
|
||||
if self.build_order is not None:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def installedItemCount(self):
|
||||
@ -622,12 +621,12 @@ class StockItem(MPTTModel):
|
||||
return self.installedItemCount() > 0
|
||||
|
||||
@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.
|
||||
|
||||
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
|
||||
user: The user performing the operation
|
||||
notes: Any notes associated with the operation
|
||||
@ -638,10 +637,10 @@ class StockItem(MPTTModel):
|
||||
return False
|
||||
|
||||
# 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:
|
||||
stock_item = otherItem
|
||||
stock_item = other_item
|
||||
|
||||
# Assign the other stock item into this one
|
||||
stock_item.belongs_to = self
|
||||
@ -738,10 +737,6 @@ class StockItem(MPTTModel):
|
||||
if self.sales_order is not None:
|
||||
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
|
||||
if self.customer is not None:
|
||||
return False
|
||||
|
@ -73,7 +73,6 @@ class StockItemSerializer(InvenTreeModelSerializer):
|
||||
return queryset.prefetch_related(
|
||||
'belongs_to',
|
||||
'build',
|
||||
'build_order',
|
||||
'customer',
|
||||
'sales_order',
|
||||
'supplier_part',
|
||||
@ -155,7 +154,6 @@ class StockItemSerializer(InvenTreeModelSerializer):
|
||||
'batch',
|
||||
'belongs_to',
|
||||
'build',
|
||||
'build_order',
|
||||
'customer',
|
||||
'in_stock',
|
||||
'is_building',
|
||||
|
@ -221,12 +221,6 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
|
||||
<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>
|
||||
</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 %}
|
||||
<tr>
|
||||
<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>
|
||||
</tr>
|
||||
{% 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 %}
|
||||
<tr>
|
||||
<td><span class='fas fa-sitemap'></span></td>
|
||||
|
@ -22,8 +22,9 @@ class StockAPITestCase(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
# Create a user for auth
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user('testuser', 'test@testing.com', 'password')
|
||||
user = get_user_model()
|
||||
|
||||
self.user = user.objects.create_user('testuser', 'test@testing.com', 'password')
|
||||
|
||||
# Add the necessary permissions to the user
|
||||
perms = [
|
||||
|
@ -23,8 +23,9 @@ class StockViewTestCase(TestCase):
|
||||
super().setUp()
|
||||
|
||||
# Create a user
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(
|
||||
user = get_user_model()
|
||||
|
||||
self.user = user.objects.create_user(
|
||||
username='username',
|
||||
email='user@email.com',
|
||||
password='password'
|
||||
|
@ -38,12 +38,12 @@ class StockTest(TestCase):
|
||||
self.drawer3 = StockLocation.objects.get(name='Drawer_3')
|
||||
|
||||
# Create a user
|
||||
User = get_user_model()
|
||||
User.objects.create_user('username', 'user@email.com', 'password')
|
||||
user = get_user_model()
|
||||
user.objects.create_user('username', 'user@email.com', '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
|
||||
Part.objects.rebuild()
|
||||
@ -221,21 +221,21 @@ class StockTest(TestCase):
|
||||
def test_split_stock(self):
|
||||
# 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.splitStock(1000, None, self.user)
|
||||
self.assertEqual(stock.quantity, 234)
|
||||
|
||||
# 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
|
||||
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)
|
||||
self.assertEqual(StockItem.objects.filter(part=3).count(), N + 1)
|
||||
self.assertEqual(StockItem.objects.filter(part=3).count(), n + 1)
|
||||
|
||||
def test_stocktake(self):
|
||||
# Perform stocktake
|
||||
|
@ -14,6 +14,8 @@ from django.urls import reverse
|
||||
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from moneyed import CURRENCIES
|
||||
|
||||
from InvenTree.views import AjaxView
|
||||
from InvenTree.views import AjaxUpdateView, AjaxDeleteView, AjaxCreateView
|
||||
from InvenTree.views import QRCodeView
|
||||
@ -32,6 +34,8 @@ from report.models import TestReport
|
||||
from label.models import StockItemLabel
|
||||
from .models import StockItem, StockLocation, StockItemTracking, StockItemAttachment, StockItemTestResult
|
||||
|
||||
import common.settings
|
||||
|
||||
from .admin import StockItemResource
|
||||
|
||||
from . import forms as StockForms
|
||||
@ -1572,6 +1576,8 @@ class StockItemCreate(AjaxCreateView):
|
||||
initials['location'] = part.get_default_location()
|
||||
initials['supplier_part'] = part.default_supplier
|
||||
|
||||
currency_code = common.settings.currency_code_default()
|
||||
|
||||
# SupplierPart field has been specified
|
||||
# It must match the Part, if that has been supplied
|
||||
if sup_part_id:
|
||||
@ -1581,6 +1587,8 @@ class StockItemCreate(AjaxCreateView):
|
||||
if part is None or supplier_part.part == part:
|
||||
initials['supplier_part'] = supplier_part
|
||||
|
||||
currency_code = supplier_part.supplier.currency_code
|
||||
|
||||
except (ValueError, SupplierPart.DoesNotExist):
|
||||
pass
|
||||
|
||||
@ -1592,6 +1600,11 @@ class StockItemCreate(AjaxCreateView):
|
||||
except (ValueError, StockLocation.DoesNotExist):
|
||||
pass
|
||||
|
||||
currency = CURRENCIES.get(currency_code, None)
|
||||
|
||||
if currency:
|
||||
initials['purchase_price'] = (None, currency)
|
||||
|
||||
return initials
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
114
InvenTree/templates/InvenTree/settings/category.html
Normal file
114
InvenTree/templates/InvenTree/settings/category.html
Normal 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 %}
|
@ -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
Loading…
Reference in New Issue
Block a user