2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-05-23 09:35:30 +00:00

Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters
2022-11-17 20:57:02 +11:00
136 changed files with 61868 additions and 49356 deletions
+42
View File
@@ -0,0 +1,42 @@
name: "Install problems"
description: "If you have problems deploying InvenTree"
labels: ["question", "triage:not-checked", "setup"]
body:
- type: checkboxes
id: deployment
attributes:
label: "Deployment Method"
options:
- label: "Installer"
- label: "Docker Development"
- label: "Docker Production"
- label: "Bare metal Development"
- label: "Bare metal Production"
- type: textarea
id: description
validations:
required: true
attributes:
label: "Describe the problem*"
description: "A clear and concise description of what is failing."
- type: textarea
id: steps-to-reproduce
validations:
required: true
attributes:
label: "Steps to Reproduce"
description: "Steps to reproduce the behavior, please make it detailed"
placeholder: |
0. Link to all docs you used
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
- type: textarea
id: logs
attributes:
label: "Relevant log output"
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
render: bash
validations:
required: false
+7
View File
@@ -6,6 +6,7 @@ from django.http import JsonResponse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from django_q.models import OrmQ
from rest_framework import filters, permissions from rest_framework import filters, permissions
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
@@ -28,6 +29,11 @@ class InfoView(AjaxView):
permission_classes = [permissions.AllowAny] permission_classes = [permissions.AllowAny]
def worker_pending_tasks(self):
"""Return the current number of outstanding background tasks"""
return OrmQ.objects.count()
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
"""Serve current server information.""" """Serve current server information."""
data = { data = {
@@ -36,6 +42,7 @@ class InfoView(AjaxView):
'instance': inventreeInstanceName(), 'instance': inventreeInstanceName(),
'apiVersion': inventreeApiVersion(), 'apiVersion': inventreeApiVersion(),
'worker_running': is_worker_running(), 'worker_running': is_worker_running(),
'worker_pending_tasks': self.worker_pending_tasks(),
'plugins_enabled': settings.PLUGINS_ENABLED, 'plugins_enabled': settings.PLUGINS_ENABLED,
'active_plugins': plugins_info(), 'active_plugins': plugins_info(),
} }
+16 -1
View File
@@ -2,11 +2,26 @@
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 79 INVENTREE_API_VERSION = 81
""" """
Increment this API version number whenever there is a significant change to the API that any clients need to know about Increment this API version number whenever there is a significant change to the API that any clients need to know about
v81 -> 2022-11-08 : https://github.com/inventree/InvenTree/pull/3710
- Adds cached pricing information to Part API
- Adds cached pricing information to BomItem API
- Allows Part and BomItem list endpoints to be filtered by 'has_pricing'
- Remove calculated 'price_string' values from API endpoints
- Allows PurchaseOrderLineItem API endpoint to be filtered by 'has_pricing'
- Allows SalesOrderLineItem API endpoint to be filtered by 'has_pricing'
- Allows SalesOrderLineItem API endpoint to be filtered by 'order_status'
- Adds more information to SupplierPriceBreak serializer
v80 -> 2022-11-07 : https://github.com/inventree/InvenTree/pull/3906
- Adds 'barcode_hash' to Part API serializer
- Adds 'barcode_hash' to StockLocation API serializer
- Adds 'barcode_hash' to SupplierPart API serializer
v79 -> 2022-11-03 : https://github.com/inventree/InvenTree/pull/3895 v79 -> 2022-11-03 : https://github.com/inventree/InvenTree/pull/3895
- Add metadata to Company - Add metadata to Company
+7
View File
@@ -68,6 +68,13 @@ class InvenTreeConfig(AppConfig):
minutes=task.minutes, minutes=task.minutes,
) )
# Put at least one task onto the backround worker stack,
# which will be processed as soon as the worker comes online
InvenTree.tasks.offload_task(
InvenTree.tasks.heartbeat,
force_async=True,
)
logger.info("Started background tasks...") logger.info("Started background tasks...")
def collect_tasks(self): def collect_tasks(self):
+12 -3
View File
@@ -38,10 +38,8 @@ class InvenTreeURLField(models.URLField):
def __init__(self, **kwargs): def __init__(self, **kwargs):
"""Initialization method for InvenTreeURLField""" """Initialization method for InvenTreeURLField"""
# Max length for InvenTreeURLField defaults to 200 # Max length for InvenTreeURLField is set to 200
if 'max_length' not in kwargs:
kwargs['max_length'] = 200 kwargs['max_length'] = 200
super().__init__(**kwargs) super().__init__(**kwargs)
@@ -69,6 +67,13 @@ class InvenTreeModelMoneyField(ModelMoneyField):
# set defaults # set defaults
kwargs.update(money_kwargs()) kwargs.update(money_kwargs())
# Default values (if not specified)
if 'max_digits' not in kwargs:
kwargs['max_digits'] = 19
if 'decimal_places' not in kwargs:
kwargs['decimal_places'] = 6
# Set a minimum value validator # Set a minimum value validator
validators = kwargs.get('validators', []) validators = kwargs.get('validators', [])
@@ -109,6 +114,10 @@ class InvenTreeMoneyField(MoneyField):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Override initial values with the real info from database.""" """Override initial values with the real info from database."""
kwargs.update(money_kwargs()) kwargs.update(money_kwargs())
kwargs['max_digits'] = 19
kwargs['decimal_places'] = 6
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
+5 -18
View File
@@ -456,7 +456,7 @@ def WrapWithQuotes(text, quote='"'):
return text return text
def MakeBarcode(object_name, object_pk, object_data=None, **kwargs): def MakeBarcode(cls_name, object_pk: int, object_data=None, **kwargs):
"""Generate a string for a barcode. Adds some global InvenTree parameters. """Generate a string for a barcode. Adds some global InvenTree parameters.
Args: Args:
@@ -468,29 +468,16 @@ def MakeBarcode(object_name, object_pk, object_data=None, **kwargs):
Returns: Returns:
json string of the supplied data plus some other data json string of the supplied data plus some other data
""" """
if object_data is None: if object_data is None:
object_data = {} object_data = {}
url = kwargs.get('url', False)
brief = kwargs.get('brief', True) brief = kwargs.get('brief', True)
data = {} data = {}
if url: if brief:
request = object_data.get('request', None) data[cls_name] = object_pk
item_url = object_data.get('item_url', None)
absolute_url = None
if request and item_url:
absolute_url = request.build_absolute_uri(item_url)
# Return URL (No JSON)
return absolute_url
if item_url:
# Return URL (No JSON)
return item_url
elif brief:
data[object_name] = object_pk
else: else:
data['tool'] = 'InvenTree' data['tool'] = 'InvenTree'
data['version'] = InvenTree.version.inventreeVersion() data['version'] = InvenTree.version.inventreeVersion()
@@ -498,7 +485,7 @@ def MakeBarcode(object_name, object_pk, object_data=None, **kwargs):
# Ensure PK is included # Ensure PK is included
object_data['id'] = object_pk object_data['id'] = object_pk
data[object_name] = object_data data[cls_name] = object_data
return json.dumps(data, sort_keys=True) return json.dumps(data, sort_keys=True)
+2 -2
View File
@@ -93,11 +93,11 @@ class InvenTreeMetadata(SimpleMetadata):
# Add a 'DELETE' action if we are allowed to delete # Add a 'DELETE' action if we are allowed to delete
if 'DELETE' in view.allowed_methods and check(user, table, 'delete'): if 'DELETE' in view.allowed_methods and check(user, table, 'delete'):
actions['DELETE'] = True actions['DELETE'] = {}
# Add a 'VIEW' action if we are allowed to view # Add a 'VIEW' action if we are allowed to view
if 'GET' in view.allowed_methods and check(user, table, 'view'): if 'GET' in view.allowed_methods and check(user, table, 'view'):
actions['GET'] = True actions['GET'] = {}
metadata['actions'] = actions metadata['actions'] = actions
+40 -1
View File
@@ -1,6 +1,6 @@
"""Mixins for (API) views in the whole project.""" """Mixins for (API) views in the whole project."""
from rest_framework import generics, status from rest_framework import generics, mixins, status
from rest_framework.response import Response from rest_framework.response import Response
from InvenTree.helpers import remove_non_printable_characters, strip_html_tags from InvenTree.helpers import remove_non_printable_characters, strip_html_tags
@@ -106,6 +106,45 @@ class RetrieveUpdateAPI(CleanMixin, generics.RetrieveUpdateAPIView):
pass pass
class CustomDestroyModelMixin:
"""This mixin was created pass the kwargs from the API to the models."""
def destroy(self, request, *args, **kwargs):
"""Custom destroy method to pass kwargs."""
instance = self.get_object()
self.perform_destroy(instance, **kwargs)
return Response(status=status.HTTP_204_NO_CONTENT)
def perform_destroy(self, instance, **kwargs):
"""Custom destroy method to pass kwargs."""
instance.delete(**kwargs)
class CustomRetrieveUpdateDestroyAPIView(mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
CustomDestroyModelMixin,
generics.GenericAPIView):
"""This APIView was created pass the kwargs from the API to the models."""
def get(self, request, *args, **kwargs):
"""Custom get method to pass kwargs."""
return self.retrieve(request, *args, **kwargs)
def put(self, request, *args, **kwargs):
"""Custom put method to pass kwargs."""
return self.update(request, *args, **kwargs)
def patch(self, request, *args, **kwargs):
"""Custom patch method to pass kwargs."""
return self.partial_update(request, *args, **kwargs)
def delete(self, request, *args, **kwargs):
"""Custom delete method to pass kwargs."""
return self.destroy(request, *args, **kwargs)
class CustomRetrieveUpdateDestroyAPI(CleanMixin, CustomRetrieveUpdateDestroyAPIView):
"""This APIView was created pass the kwargs from the API to the models."""
class RetrieveUpdateDestroyAPI(CleanMixin, generics.RetrieveUpdateDestroyAPIView): class RetrieveUpdateDestroyAPI(CleanMixin, generics.RetrieveUpdateDestroyAPIView):
"""View for retrieve, update and destroy API.""" """View for retrieve, update and destroy API."""
+3 -2
View File
@@ -21,7 +21,7 @@ from rest_framework.serializers import DecimalField
from rest_framework.utils import model_meta from rest_framework.utils import model_meta
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
from InvenTree.fields import InvenTreeRestURLField from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField
from InvenTree.helpers import download_image_from_url from InvenTree.helpers import download_image_from_url
@@ -34,7 +34,7 @@ class InvenTreeMoneySerializer(MoneyField):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Overrite default values.""" """Overrite default values."""
kwargs["max_digits"] = kwargs.get("max_digits", 19) kwargs["max_digits"] = kwargs.get("max_digits", 19)
self.decimal_places = kwargs["decimal_places"] = kwargs.get("decimal_places", 4) self.decimal_places = kwargs["decimal_places"] = kwargs.get("decimal_places", 6)
kwargs["required"] = kwargs.get("required", False) kwargs["required"] = kwargs.get("required", False)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -73,6 +73,7 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
serializer_field_mapping = { serializer_field_mapping = {
**serializers.ModelSerializer.serializer_field_mapping, **serializers.ModelSerializer.serializer_field_mapping,
models.URLField: InvenTreeRestURLField, models.URLField: InvenTreeRestURLField,
InvenTreeURLField: InvenTreeRestURLField,
} }
def __init__(self, instance=None, data=empty, **kwargs): def __init__(self, instance=None, data=empty, **kwargs):
+10 -1
View File
@@ -26,10 +26,12 @@ from sentry_sdk.integrations.django import DjangoIntegration
from . import config from . import config
from .config import get_boolean_setting, get_custom_file, get_setting from .config import get_boolean_setting, get_custom_file, get_setting
INVENTREE_NEWS_URL = 'https://inventree.org/news/feed.atom'
# Determine if we are running in "test" mode e.g. "manage.py test" # Determine if we are running in "test" mode e.g. "manage.py test"
TESTING = 'test' in sys.argv TESTING = 'test' in sys.argv
# Are enviroment variables manipulated by tests? Needs to be set by testing code # Are environment variables manipulated by tests? Needs to be set by testing code
TESTING_ENV = False TESTING_ENV = False
# New requirement for django 3.2+ # New requirement for django 3.2+
@@ -562,12 +564,16 @@ else:
# django-q background worker configuration # django-q background worker configuration
Q_CLUSTER = { Q_CLUSTER = {
'name': 'InvenTree', 'name': 'InvenTree',
'label': 'Background Tasks',
'workers': int(get_setting('INVENTREE_BACKGROUND_WORKERS', 'background.workers', 4)), 'workers': int(get_setting('INVENTREE_BACKGROUND_WORKERS', 'background.workers', 4)),
'timeout': int(get_setting('INVENTREE_BACKGROUND_TIMEOUT', 'background.timeout', 90)), 'timeout': int(get_setting('INVENTREE_BACKGROUND_TIMEOUT', 'background.timeout', 90)),
'retry': 120, 'retry': 120,
'max_attempts': 5,
'queue_limit': 50, 'queue_limit': 50,
'catch_up': False,
'bulk': 10, 'bulk': 10,
'orm': 'default', 'orm': 'default',
'cache': 'default',
'sync': False, 'sync': False,
} }
@@ -672,6 +678,9 @@ CURRENCIES = CONFIG.get(
], ],
) )
# Maximum number of decimal places for currency rendering
CURRENCY_DECIMAL_PLACES = 6
# Check that each provided currency is supported # Check that each provided currency is supported
for currency in CURRENCIES: for currency in CURRENCIES:
if currency not in moneyed.CURRENCIES: # pragma: no cover if currency not in moneyed.CURRENCIES: # pragma: no cover
+3 -4
View File
@@ -26,15 +26,14 @@ def is_worker_running(**kwargs):
""" """
Sometimes Stat.get_all() returns []. Sometimes Stat.get_all() returns [].
In this case we have the 'heartbeat' task running every 15 minutes. In this case we have the 'heartbeat' task running every 5 minutes.
Check to see if we have a result within the last 20 minutes Check to see if we have any successful result within the last 10 minutes
""" """
now = timezone.now() now = timezone.now()
past = now - timedelta(minutes=20) past = now - timedelta(minutes=10)
results = Success.objects.filter( results = Success.objects.filter(
func='InvenTree.tasks.heartbeat',
started__gte=past started__gte=past
) )
+1 -1
View File
@@ -200,7 +200,7 @@ def scheduled_task(interval: str, minutes: int = None, tasklist: TaskRegister =
return _task_wrapper return _task_wrapper
@scheduled_task(ScheduledTask.MINUTES, 15) @scheduled_task(ScheduledTask.MINUTES, 5)
def heartbeat(): def heartbeat():
"""Simple task which runs at 5 minute intervals, so we can determine that the background worker is actually running. """Simple task which runs at 5 minute intervals, so we can determine that the background worker is actually running.
+1 -1
View File
@@ -72,7 +72,7 @@ class ViewTests(InvenTreeTestCase):
'server', 'server',
'login', 'login',
'barcodes', 'barcodes',
'currencies', 'pricing',
'parts', 'parts',
'stock', 'stock',
] ]
+7 -6
View File
@@ -275,8 +275,10 @@ class TestHelpers(TestCase):
we will simply try multiple times we will simply try multiple times
""" """
tries = 0
with self.assertRaises(expected_error): with self.assertRaises(expected_error):
while retries > 0: while tries < retries:
try: try:
helpers.download_image_from_url(url, timeout=timeout) helpers.download_image_from_url(url, timeout=timeout)
@@ -285,9 +287,11 @@ class TestHelpers(TestCase):
if type(exc) is expected_error: if type(exc) is expected_error:
# Re-throw this error # Re-throw this error
raise exc raise exc
else:
print("Unexpected error:", type(exc), exc)
time.sleep(30) tries += 1
retries -= 1 time.sleep(10 * tries)
# Attempt to download an image which throws a 404 # Attempt to download an image which throws a 404
dl_helper("https://httpstat.us/404", requests.exceptions.HTTPError, timeout=10) dl_helper("https://httpstat.us/404", requests.exceptions.HTTPError, timeout=10)
@@ -295,9 +299,6 @@ class TestHelpers(TestCase):
# Attempt to download, but timeout # Attempt to download, but timeout
dl_helper("https://httpstat.us/200?sleep=5000", requests.exceptions.ReadTimeout, timeout=1) dl_helper("https://httpstat.us/200?sleep=5000", requests.exceptions.ReadTimeout, timeout=1)
# Attempt to download, but not a valid image
dl_helper("https://httpstat.us/200", TypeError, timeout=10)
large_img = "https://github.com/inventree/InvenTree/raw/master/InvenTree/InvenTree/static/img/paper_splash_large.jpg" large_img = "https://github.com/inventree/InvenTree/raw/master/InvenTree/InvenTree/static/img/paper_splash_large.jpg"
InvenTreeSetting.set_setting('INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE', 1, change_user=None) InvenTreeSetting.set_setting('INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE', 1, change_user=None)
+3
View File
@@ -98,6 +98,7 @@ translated_javascript_urls = [
re_path(r'^barcode.js', DynamicJsView.as_view(template_name='js/translated/barcode.js'), name='barcode.js'), re_path(r'^barcode.js', DynamicJsView.as_view(template_name='js/translated/barcode.js'), name='barcode.js'),
re_path(r'^bom.js', DynamicJsView.as_view(template_name='js/translated/bom.js'), name='bom.js'), re_path(r'^bom.js', DynamicJsView.as_view(template_name='js/translated/bom.js'), name='bom.js'),
re_path(r'^build.js', DynamicJsView.as_view(template_name='js/translated/build.js'), name='build.js'), re_path(r'^build.js', DynamicJsView.as_view(template_name='js/translated/build.js'), name='build.js'),
re_path(r'^charts.js', DynamicJsView.as_view(template_name='js/translated/charts.js'), name='charts.js'),
re_path(r'^company.js', DynamicJsView.as_view(template_name='js/translated/company.js'), name='company.js'), re_path(r'^company.js', DynamicJsView.as_view(template_name='js/translated/company.js'), name='company.js'),
re_path(r'^filters.js', DynamicJsView.as_view(template_name='js/translated/filters.js'), name='filters.js'), re_path(r'^filters.js', DynamicJsView.as_view(template_name='js/translated/filters.js'), name='filters.js'),
re_path(r'^forms.js', DynamicJsView.as_view(template_name='js/translated/forms.js'), name='forms.js'), re_path(r'^forms.js', DynamicJsView.as_view(template_name='js/translated/forms.js'), name='forms.js'),
@@ -111,6 +112,8 @@ translated_javascript_urls = [
re_path(r'^search.js', DynamicJsView.as_view(template_name='js/translated/search.js'), name='search.js'), re_path(r'^search.js', DynamicJsView.as_view(template_name='js/translated/search.js'), name='search.js'),
re_path(r'^stock.js', DynamicJsView.as_view(template_name='js/translated/stock.js'), name='stock.js'), re_path(r'^stock.js', DynamicJsView.as_view(template_name='js/translated/stock.js'), name='stock.js'),
re_path(r'^plugin.js', DynamicJsView.as_view(template_name='js/translated/plugin.js'), name='plugin.js'), re_path(r'^plugin.js', DynamicJsView.as_view(template_name='js/translated/plugin.js'), name='plugin.js'),
re_path(r'^pricing.js', DynamicJsView.as_view(template_name='js/translated/pricing.js'), name='pricing.js'),
re_path(r'^news.js', DynamicJsView.as_view(template_name='js/translated/news.js'), name='news.js'),
re_path(r'^tables.js', DynamicJsView.as_view(template_name='js/translated/tables.js'), name='tables.js'), re_path(r'^tables.js', DynamicJsView.as_view(template_name='js/translated/tables.js'), name='tables.js'),
re_path(r'^table_filters.js', DynamicJsView.as_view(template_name='js/translated/table_filters.js'), name='table_filters.js'), re_path(r'^table_filters.js', DynamicJsView.as_view(template_name='js/translated/table_filters.js'), name='table_filters.js'),
re_path(r'^notification.js', DynamicJsView.as_view(template_name='js/translated/notification.js'), name='notification.js'), re_path(r'^notification.js', DynamicJsView.as_view(template_name='js/translated/notification.js'), name='notification.js'),
+7
View File
@@ -55,9 +55,16 @@ class NotificationMessageAdmin(admin.ModelAdmin):
search_fields = ('name', 'category', 'message', ) search_fields = ('name', 'category', 'message', )
class NewsFeedEntryAdmin(admin.ModelAdmin):
"""Admin settings for NewsFeedEntry."""
list_display = ('title', 'author', 'published', 'summary', )
admin.site.register(common.models.InvenTreeSetting, SettingsAdmin) admin.site.register(common.models.InvenTreeSetting, SettingsAdmin)
admin.site.register(common.models.InvenTreeUserSetting, UserSettingsAdmin) admin.site.register(common.models.InvenTreeUserSetting, UserSettingsAdmin)
admin.site.register(common.models.WebhookEndpoint, WebhookAdmin) admin.site.register(common.models.WebhookEndpoint, WebhookAdmin)
admin.site.register(common.models.WebhookMessage, ImportExportModelAdmin) admin.site.register(common.models.WebhookMessage, ImportExportModelAdmin)
admin.site.register(common.models.NotificationEntry, NotificationEntryAdmin) admin.site.register(common.models.NotificationEntry, NotificationEntryAdmin)
admin.site.register(common.models.NotificationMessage, NotificationMessageAdmin) admin.site.register(common.models.NotificationMessage, NotificationMessageAdmin)
admin.site.register(common.models.NewsFeedEntry, NewsFeedEntryAdmin)
+51 -65
View File
@@ -11,6 +11,7 @@ from django_filters.rest_framework import DjangoFilterBackend
from django_q.tasks import async_task from django_q.tasks import async_task
from rest_framework import filters, permissions, serializers from rest_framework import filters, permissions, serializers
from rest_framework.exceptions import NotAcceptable, NotFound from rest_framework.exceptions import NotAcceptable, NotFound
from rest_framework.permissions import IsAdminUser
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
@@ -18,8 +19,8 @@ import common.models
import common.serializers import common.serializers
from InvenTree.api import BulkDeleteMixin from InvenTree.api import BulkDeleteMixin
from InvenTree.helpers import inheritors from InvenTree.helpers import inheritors
from InvenTree.mixins import (CreateAPI, ListAPI, RetrieveAPI, from InvenTree.mixins import (ListAPI, RetrieveAPI, RetrieveUpdateAPI,
RetrieveUpdateAPI, RetrieveUpdateDestroyAPI) RetrieveUpdateDestroyAPI)
from plugin.models import NotificationUserSetting from plugin.models import NotificationUserSetting
from plugin.serializers import NotificationUserSettingSerializer from plugin.serializers import NotificationUserSettingSerializer
@@ -255,21 +256,20 @@ class NotificationUserSettingsDetail(RetrieveUpdateAPI):
queryset = NotificationUserSetting.objects.all() queryset = NotificationUserSetting.objects.all()
serializer_class = NotificationUserSettingSerializer serializer_class = NotificationUserSettingSerializer
permission_classes = [UserSettingsPermissions, ]
permission_classes = [
UserSettingsPermissions,
]
class NotificationList(BulkDeleteMixin, ListAPI): class NotificationMessageMixin:
"""List view for all notifications of the current user.""" """Generic mixin for NotificationMessage."""
queryset = common.models.NotificationMessage.objects.all() queryset = common.models.NotificationMessage.objects.all()
serializer_class = common.serializers.NotificationMessageSerializer serializer_class = common.serializers.NotificationMessageSerializer
permission_classes = [UserSettingsPermissions, ]
permission_classes = [
permissions.IsAuthenticated, class NotificationList(NotificationMessageMixin, BulkDeleteMixin, ListAPI):
] """List view for all notifications of the current user."""
permission_classes = [permissions.IsAuthenticated, ]
filter_backends = [ filter_backends = [
DjangoFilterBackend, DjangoFilterBackend,
@@ -312,65 +312,16 @@ class NotificationList(BulkDeleteMixin, ListAPI):
return queryset return queryset
class NotificationDetail(RetrieveUpdateDestroyAPI): class NotificationDetail(NotificationMessageMixin, RetrieveUpdateDestroyAPI):
"""Detail view for an individual notification object. """Detail view for an individual notification object.
- User can only view / delete their own notification objects - User can only view / delete their own notification objects
""" """
queryset = common.models.NotificationMessage.objects.all()
serializer_class = common.serializers.NotificationMessageSerializer
permission_classes = [
UserSettingsPermissions,
]
class NotificationReadAll(NotificationMessageMixin, RetrieveAPI):
class NotificationReadEdit(CreateAPI):
"""General API endpoint to manipulate read state of a notification."""
queryset = common.models.NotificationMessage.objects.all()
serializer_class = common.serializers.NotificationReadSerializer
permission_classes = [
UserSettingsPermissions,
]
def get_serializer_context(self):
"""Add instance to context so it can be accessed in the serializer."""
context = super().get_serializer_context()
if self.request:
context['instance'] = self.get_object()
return context
def perform_create(self, serializer):
"""Set the `read` status to the target value."""
message = self.get_object()
try:
message.read = self.target
message.save()
except Exception as exc:
raise serializers.ValidationError(detail=serializers.as_serializer_error(exc))
class NotificationRead(NotificationReadEdit):
"""API endpoint to mark a notification as read."""
target = True
class NotificationUnread(NotificationReadEdit):
"""API endpoint to mark a notification as unread."""
target = False
class NotificationReadAll(RetrieveAPI):
"""API endpoint to mark all notifications as read.""" """API endpoint to mark all notifications as read."""
queryset = common.models.NotificationMessage.objects.all()
permission_classes = [
UserSettingsPermissions,
]
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
"""Set all messages for the current user as read.""" """Set all messages for the current user as read."""
try: try:
@@ -380,6 +331,35 @@ class NotificationReadAll(RetrieveAPI):
raise serializers.ValidationError(detail=serializers.as_serializer_error(exc)) raise serializers.ValidationError(detail=serializers.as_serializer_error(exc))
class NewsFeedMixin:
"""Generic mixin for NewsFeedEntry."""
queryset = common.models.NewsFeedEntry.objects.all()
serializer_class = common.serializers.NewsFeedEntrySerializer
permission_classes = [IsAdminUser, ]
class NewsFeedEntryList(NewsFeedMixin, BulkDeleteMixin, ListAPI):
"""List view for all news items."""
filter_backends = [
DjangoFilterBackend,
filters.OrderingFilter,
]
ordering_fields = [
'published',
'author',
'read',
]
filterset_fields = [
'read',
]
class NewsFeedEntryDetail(NewsFeedMixin, RetrieveUpdateDestroyAPI):
"""Detail view for an individual news feed object."""
settings_api_urls = [ settings_api_urls = [
# User settings # User settings
re_path(r'^user/', include([ re_path(r'^user/', include([
@@ -417,8 +397,6 @@ common_api_urls = [
re_path(r'^notifications/', include([ re_path(r'^notifications/', include([
# Individual purchase order detail URLs # Individual purchase order detail URLs
re_path(r'^(?P<pk>\d+)/', include([ re_path(r'^(?P<pk>\d+)/', include([
re_path(r'^read/', NotificationRead.as_view(), name='api-notifications-read'),
re_path(r'^unread/', NotificationUnread.as_view(), name='api-notifications-unread'),
re_path(r'.*$', NotificationDetail.as_view(), name='api-notifications-detail'), re_path(r'.*$', NotificationDetail.as_view(), name='api-notifications-detail'),
])), ])),
# Read all # Read all
@@ -428,4 +406,12 @@ common_api_urls = [
re_path(r'^.*$', NotificationList.as_view(), name='api-notifications-list'), re_path(r'^.*$', NotificationList.as_view(), name='api-notifications-list'),
])), ])),
# News
re_path(r'^news/', include([
re_path(r'^(?P<pk>\d+)/', include([
re_path(r'.*$', NewsFeedEntryDetail.as_view(), name='api-news-detail'),
])),
re_path(r'^.*$', NewsFeedEntryList.as_view(), name='api-news-list'),
])),
] ]
@@ -0,0 +1,26 @@
# Generated by Django 3.2.14 on 2022-07-31 19:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('common', '0014_notificationmessage'),
]
operations = [
migrations.CreateModel(
name='NewsFeedEntry',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('feed_id', models.CharField(max_length=250, unique=True, verbose_name='Id')),
('title', models.CharField(max_length=250, verbose_name='Title')),
('link', models.URLField(max_length=250, verbose_name='Link')),
('published', models.DateTimeField(max_length=250, verbose_name='Published')),
('author', models.CharField(max_length=250, verbose_name='Author')),
('summary', models.CharField(max_length=250, verbose_name='Summary')),
('read', models.BooleanField(default=False, help_text='Was this news item read?', verbose_name='Read')),
],
),
]
+126 -57
View File
@@ -1054,37 +1054,6 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': bool, 'validator': bool,
}, },
'PART_SHOW_PRICE_IN_FORMS': {
'name': _('Show Price in Forms'),
'description': _('Display part price in some forms'),
'default': True,
'validator': bool,
},
# 2021-10-08
# This setting exists as an interim solution for https://github.com/inventree/InvenTree/issues/2042
# The BOM API can be extremely slow when calculating pricing information "on the fly"
# A future solution will solve this properly,
# but as an interim step we provide a global to enable / disable BOM pricing
'PART_SHOW_PRICE_IN_BOM': {
'name': _('Show Price in BOM'),
'description': _('Include pricing information in BOM tables'),
'default': True,
'validator': bool,
},
# 2022-02-03
# This setting exists as an interim solution for extremely slow part page load times when the part has a complex BOM
# In an upcoming release, pricing history (and BOM pricing) will be cached,
# rather than having to be re-calculated every time the page is loaded!
# For now, we will simply hide part pricing by default
'PART_SHOW_PRICE_HISTORY': {
'name': _('Show Price History'),
'description': _('Display historical pricing for Part'),
'default': False,
'validator': bool,
},
'PART_SHOW_RELATED': { 'PART_SHOW_RELATED': {
'name': _('Show related parts'), 'name': _('Show related parts'),
'description': _('Display related parts for a part'), 'description': _('Display related parts for a part'),
@@ -1099,20 +1068,6 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': bool, 'validator': bool,
}, },
'PART_INTERNAL_PRICE': {
'name': _('Internal Prices'),
'description': _('Enable internal prices for parts'),
'default': False,
'validator': bool
},
'PART_BOM_USE_INTERNAL_PRICE': {
'name': _('Internal Price as BOM-Price'),
'description': _('Use the internal price (if set) in BOM-price calculations'),
'default': False,
'validator': bool
},
'PART_NAME_FORMAT': { 'PART_NAME_FORMAT': {
'name': _('Part Name Display Format'), 'name': _('Part Name Display Format'),
'description': _('Format to display the part name'), 'description': _('Format to display the part name'),
@@ -1127,6 +1082,49 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': '', 'default': '',
}, },
'PRICING_DECIMAL_PLACES': {
'name': _('Pricing Decimal Places'),
'description': _('Number of decimal places to display when rendering pricing data'),
'default': 6,
'validator': [
int,
MinValueValidator(2),
MaxValueValidator(6)
]
},
'PRICING_USE_SUPPLIER_PRICING': {
'name': _('Use Supplier Pricing'),
'description': _('Include supplier price breaks in overall pricing calculations'),
'default': True,
'validator': bool,
},
'PRICING_UPDATE_DAYS': {
'name': _('Pricing Rebuild Time'),
'description': _('Number of days before part pricing is automatically updated'),
'units': _('days'),
'default': 30,
'validator': [
int,
MinValueValidator(10),
]
},
'PART_INTERNAL_PRICE': {
'name': _('Internal Prices'),
'description': _('Enable internal prices for parts'),
'default': False,
'validator': bool
},
'PART_BOM_USE_INTERNAL_PRICE': {
'name': _('Internal Price Override'),
'description': _('If available, internal prices override price range calculations'),
'default': False,
'validator': bool
},
'LABEL_ENABLE': { 'LABEL_ENABLE': {
'name': _('Enable label printing'), 'name': _('Enable label printing'),
'description': _('Enable label printing from the web interface'), 'description': _('Enable label printing from the web interface'),
@@ -1259,6 +1257,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': bool, 'validator': bool,
}, },
'SALESORDER_EDIT_COMPLETED_ORDERS': {
'name': _('Edit Completed Sales Orders'),
'description': _('Allow editing of sales orders after they have been shipped or completed'),
'default': False,
'validator': bool,
},
'PURCHASEORDER_REFERENCE_PATTERN': { 'PURCHASEORDER_REFERENCE_PATTERN': {
'name': _('Purchase Order Reference Pattern'), 'name': _('Purchase Order Reference Pattern'),
'description': _('Required pattern for generating Purchase Order reference field'), 'description': _('Required pattern for generating Purchase Order reference field'),
@@ -1266,6 +1271,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': order.validators.validate_purchase_order_reference_pattern, 'validator': order.validators.validate_purchase_order_reference_pattern,
}, },
'PURCHASEORDER_EDIT_COMPLETED_ORDERS': {
'name': _('Edit Completed Purchase Orders'),
'description': _('Allow editing of purchase orders after they have been shipped or completed'),
'default': False,
'validator': bool,
},
# login / SSO # login / SSO
'LOGIN_ENABLE_PWD_FORGOT': { 'LOGIN_ENABLE_PWD_FORGOT': {
'name': _('Enable password forgot'), 'name': _('Enable password forgot'),
@@ -1448,10 +1460,10 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'validator': [int, MinValueValidator(1)] 'validator': [int, MinValueValidator(1)]
}, },
'HOMEPAGE_BOM_VALIDATION': { 'HOMEPAGE_BOM_REQUIRES_VALIDATION': {
'name': _('Show unvalidated BOMs'), 'name': _('Show unvalidated BOMs'),
'description': _('Show BOMs that await validation on the homepage'), 'description': _('Show BOMs that await validation on the homepage'),
'default': True, 'default': False,
'validator': bool, 'validator': bool,
}, },
@@ -1476,17 +1488,17 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'validator': bool, 'validator': bool,
}, },
'HOMEPAGE_STOCK_DEPLETED': { 'HOMEPAGE_SHOW_STOCK_DEPLETED': {
'name': _('Show depleted stock'), 'name': _('Show depleted stock'),
'description': _('Show depleted stock items on the homepage'), 'description': _('Show depleted stock items on the homepage'),
'default': True, 'default': False,
'validator': bool, 'validator': bool,
}, },
'HOMEPAGE_STOCK_NEEDED': { 'HOMEPAGE_BUILD_STOCK_NEEDED': {
'name': _('Show needed stock'), 'name': _('Show needed stock'),
'description': _('Show stock items needed for builds on the homepage'), 'description': _('Show stock items needed for builds on the homepage'),
'default': True, 'default': False,
'validator': bool, 'validator': bool,
}, },
@@ -1546,6 +1558,13 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'validator': bool, 'validator': bool,
}, },
'HOMEPAGE_NEWS': {
'name': _('Show News'),
'description': _('Show news on the homepage'),
'default': False,
'validator': bool,
},
"LABEL_INLINE": { "LABEL_INLINE": {
'name': _('Inline label display'), 'name': _('Inline label display'),
'description': _('Display PDF labels in the browser, instead of downloading as a file'), 'description': _('Display PDF labels in the browser, instead of downloading as a file'),
@@ -1779,7 +1798,7 @@ class PriceBreak(models.Model):
price = InvenTree.fields.InvenTreeModelMoneyField( price = InvenTree.fields.InvenTreeModelMoneyField(
max_digits=19, max_digits=19,
decimal_places=4, decimal_places=6,
null=True, null=True,
verbose_name=_('Price'), verbose_name=_('Price'),
help_text=_('Unit price at specified quantity'), help_text=_('Unit price at specified quantity'),
@@ -2187,14 +2206,13 @@ class NotificationEntry(models.Model):
class NotificationMessage(models.Model): class NotificationMessage(models.Model):
"""A NotificationEntry records the last time a particular notifaction was sent out. """A NotificationMessage is a message sent to a particular user, notifying them of some *important information*
It is recorded to ensure that notifications are not sent out "too often" to users. Notification messages can be generated by a variety of sources.
Attributes: Attributes:
- key: A text entry describing the notification e.g. 'part.notify_low_stock' target_object: The 'target' of the notification message
- uid: An (optional) numerical ID for a particular instance source_object: The 'source' of the notification message
- date: The last time this notification was sent
""" """
# generic link to target # generic link to target
@@ -2271,3 +2289,54 @@ class NotificationMessage(models.Model):
def age_human(self): def age_human(self):
"""Humanized age.""" """Humanized age."""
return naturaltime(self.creation) return naturaltime(self.creation)
class NewsFeedEntry(models.Model):
"""A NewsFeedEntry represents an entry on the RSS/Atom feed that is generated for InvenTree news.
Attributes:
- feed_id: Unique id for the news item
- title: Title for the news item
- link: Link to the news item
- published: Date of publishing of the news item
- author: Author of news item
- summary: Summary of the news items content
- read: Was this iteam already by a superuser?
"""
feed_id = models.CharField(
verbose_name=_('Id'),
unique=True,
max_length=250,
)
title = models.CharField(
verbose_name=_('Title'),
max_length=250,
)
link = models.URLField(
verbose_name=_('Link'),
max_length=250,
)
published = models.DateTimeField(
verbose_name=_('Published'),
max_length=250,
)
author = models.CharField(
verbose_name=_('Author'),
max_length=250,
)
summary = models.CharField(
verbose_name=_('Summary'),
max_length=250,
)
read = models.BooleanField(
verbose_name=_('Read'),
help_text=_('Was this news item read?'),
default=False
)
+1 -1
View File
@@ -173,7 +173,7 @@ class MethodStorageClass:
user_settings = {} user_settings = {}
def collect(self, selected_classes=None): def collect(self, selected_classes=None):
"""Collect all classes in the enviroment that are notification methods. """Collect all classes in the environment that are notification methods.
Can be filtered to only include provided classes for testing. Can be filtered to only include provided classes for testing.
+20 -9
View File
@@ -5,7 +5,7 @@ from django.urls import reverse
from rest_framework import serializers from rest_framework import serializers
from common.models import (InvenTreeSetting, InvenTreeUserSetting, from common.models import (InvenTreeSetting, InvenTreeUserSetting,
NotificationMessage) NewsFeedEntry, NotificationMessage)
from InvenTree.helpers import construct_absolute_url, get_objectreference from InvenTree.helpers import construct_absolute_url, get_objectreference
from InvenTree.serializers import InvenTreeModelSerializer from InvenTree.serializers import InvenTreeModelSerializer
@@ -158,7 +158,7 @@ class NotificationMessageSerializer(InvenTreeModelSerializer):
age_human = serializers.CharField(read_only=True) age_human = serializers.CharField(read_only=True)
read = serializers.BooleanField(read_only=True) read = serializers.BooleanField()
def get_target(self, obj): def get_target(self, obj):
"""Function to resolve generic object reference to target.""" """Function to resolve generic object reference to target."""
@@ -203,11 +203,22 @@ class NotificationMessageSerializer(InvenTreeModelSerializer):
] ]
class NotificationReadSerializer(NotificationMessageSerializer): class NewsFeedEntrySerializer(InvenTreeModelSerializer):
"""Serializer for reading a notification.""" """Serializer for the NewsFeedEntry model."""
def is_valid(self, raise_exception=False): read = serializers.BooleanField()
"""Ensure instance data is available for view and let validation pass."""
self.instance = self.context['instance'] # set instance that should be returned class Meta:
self._validated_data = True """Meta options for NewsFeedEntrySerializer."""
return True
model = NewsFeedEntry
fields = [
'pk',
'feed_id',
'title',
'link',
'published',
'author',
'summary',
'read',
]
+41
View File
@@ -3,8 +3,11 @@
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.conf import settings
from django.core.exceptions import AppRegistryNotReady from django.core.exceptions import AppRegistryNotReady
import feedparser
from InvenTree.tasks import ScheduledTask, scheduled_task from InvenTree.tasks import ScheduledTask, scheduled_task
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
@@ -26,3 +29,41 @@ def delete_old_notifications():
# Delete notification records before the specified date # Delete notification records before the specified date
NotificationEntry.objects.filter(updated__lte=before).delete() NotificationEntry.objects.filter(updated__lte=before).delete()
@scheduled_task(ScheduledTask.DAILY)
def update_news_feed():
"""Update the newsfeed."""
try:
from common.models import NewsFeedEntry
except AppRegistryNotReady: # pragma: no cover
logger.info("Could not perform 'update_news_feed' - App registry not ready")
return
# Fetch and parse feed
try:
d = feedparser.parse(settings.INVENTREE_NEWS_URL)
except Exception as entry: # pragma: no cover
logger.warning("update_news_feed: Error parsing the newsfeed", entry)
return
# Get a reference list
id_list = [a.feed_id for a in NewsFeedEntry.objects.all()]
# Iterate over entries
for entry in d.entries:
# Check if id already exsists
if entry.id in id_list:
continue
# Create entry
NewsFeedEntry.objects.create(
feed_id=entry.id,
title=entry.title,
link=entry.link,
published=entry.published,
author=entry.author,
summary=entry.summary,
)
logger.info('update_news_feed: Sync done')
+10
View File
@@ -57,6 +57,12 @@ class SupplierPartResource(InvenTreeResource):
clean_model_instances = True clean_model_instances = True
class SupplierPriceBreakInline(admin.TabularInline):
"""Inline for supplier-part pricing"""
model = SupplierPriceBreak
class SupplierPartAdmin(ImportExportModelAdmin): class SupplierPartAdmin(ImportExportModelAdmin):
"""Admin class for the SupplierPart model""" """Admin class for the SupplierPart model"""
@@ -71,6 +77,10 @@ class SupplierPartAdmin(ImportExportModelAdmin):
'SKU', 'SKU',
] ]
inlines = [
SupplierPriceBreakInline,
]
autocomplete_fields = ('part', 'supplier', 'manufacturer_part',) autocomplete_fields = ('part', 'supplier', 'manufacturer_part',)
+48 -5
View File
@@ -7,6 +7,7 @@ from django_filters import rest_framework as rest_filters
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters from rest_framework import filters
import part.models
from InvenTree.api import AttachmentMixin, ListCreateDestroyAPIView from InvenTree.api import AttachmentMixin, ListCreateDestroyAPIView
from InvenTree.filters import InvenTreeOrderingFilter from InvenTree.filters import InvenTreeOrderingFilter
from InvenTree.helpers import str2bool from InvenTree.helpers import str2bool
@@ -354,9 +355,6 @@ class SupplierPartList(ListCreateDestroyAPIView):
InvenTreeOrderingFilter, InvenTreeOrderingFilter,
] ]
filterset_fields = [
]
ordering_fields = [ ordering_fields = [
'SKU', 'SKU',
'part', 'part',
@@ -403,6 +401,31 @@ class SupplierPartDetail(RetrieveUpdateDestroyAPI):
] ]
class SupplierPriceBreakFilter(rest_filters.FilterSet):
"""Custom API filters for the SupplierPriceBreak list endpoint"""
base_part = rest_filters.ModelChoiceFilter(
label='Base Part',
queryset=part.models.Part.objects.all(),
field_name='part__part',
)
supplier = rest_filters.ModelChoiceFilter(
label='Supplier',
queryset=Company.objects.all(),
field_name='part__supplier',
)
class Meta:
"""Metaclass options"""
model = SupplierPriceBreak
fields = [
'part',
'quantity',
]
class SupplierPriceBreakList(ListCreateAPI): class SupplierPriceBreakList(ListCreateAPI):
"""API endpoint for list view of SupplierPriceBreak object. """API endpoint for list view of SupplierPriceBreak object.
@@ -412,15 +435,35 @@ class SupplierPriceBreakList(ListCreateAPI):
queryset = SupplierPriceBreak.objects.all() queryset = SupplierPriceBreak.objects.all()
serializer_class = SupplierPriceBreakSerializer serializer_class = SupplierPriceBreakSerializer
filterset_class = SupplierPriceBreakFilter
def get_serializer(self, *args, **kwargs):
"""Return serializer instance for this endpoint"""
try:
params = self.request.query_params
kwargs['part_detail'] = str2bool(params.get('part_detail', False))
kwargs['supplier_detail'] = str2bool(params.get('supplier_detail', False))
except AttributeError:
pass
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
filter_backends = [ filter_backends = [
DjangoFilterBackend, DjangoFilterBackend,
filters.OrderingFilter,
] ]
filterset_fields = [ ordering_fields = [
'part', 'quantity',
] ]
ordering = 'quantity'
class SupplierPriceBreakDetail(RetrieveUpdateDestroyAPI): class SupplierPriceBreakDetail(RetrieveUpdateDestroyAPI):
"""Detail endpoint for SupplierPriceBreak object.""" """Detail endpoint for SupplierPriceBreak object."""
@@ -0,0 +1,19 @@
# Generated by Django 3.2.16 on 2022-11-10 01:08
import InvenTree.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('company', '0049_company_metadata'),
]
operations = [
migrations.AlterField(
model_name='company',
name='website',
field=InvenTree.fields.InvenTreeURLField(blank=True, help_text='Company website URL', verbose_name='Website'),
),
]
@@ -0,0 +1,20 @@
# Generated by Django 3.2.16 on 2022-11-11 01:50
import InvenTree.fields
from django.db import migrations
import djmoney.models.validators
class Migration(migrations.Migration):
dependencies = [
('company', '0050_alter_company_website'),
]
operations = [
migrations.AlterField(
model_name='supplierpricebreak',
name='price',
field=InvenTree.fields.InvenTreeModelMoneyField(currency_choices=[], decimal_places=6, default_currency='', help_text='Unit price at specified quantity', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Price'),
),
]
+25 -1
View File
@@ -8,6 +8,8 @@ from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.db import models from django.db import models
from django.db.models import Q, Sum, UniqueConstraint from django.db.models import Q, Sum, UniqueConstraint
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -18,6 +20,8 @@ import common.models
import common.settings import common.settings
import InvenTree.fields import InvenTree.fields
import InvenTree.helpers import InvenTree.helpers
import InvenTree.ready
import InvenTree.tasks
import InvenTree.validators import InvenTree.validators
from common.settings import currency_code_default from common.settings import currency_code_default
from InvenTree.fields import InvenTreeURLField, RoundingDecimalField from InvenTree.fields import InvenTreeURLField, RoundingDecimalField
@@ -101,7 +105,7 @@ class Company(MetadataMixin, models.Model):
blank=True, blank=True,
) )
website = models.URLField( website = InvenTreeURLField(
blank=True, blank=True,
verbose_name=_('Website'), verbose_name=_('Website'),
help_text=_('Company website URL') help_text=_('Company website URL')
@@ -691,3 +695,23 @@ class SupplierPriceBreak(common.models.PriceBreak):
def __str__(self): def __str__(self):
"""Format a string representation of a SupplierPriceBreak instance""" """Format a string representation of a SupplierPriceBreak instance"""
return f'{self.part.SKU} - {self.price} @ {self.quantity}' return f'{self.part.SKU} - {self.price} @ {self.quantity}'
@receiver(post_save, sender=SupplierPriceBreak, dispatch_uid='post_save_supplier_price_break')
def after_save_supplier_price(sender, instance, created, **kwargs):
"""Callback function when a SupplierPriceBreak is created or updated"""
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
if instance.part and instance.part.part:
instance.part.part.pricing.schedule_for_update()
@receiver(post_delete, sender=SupplierPriceBreak, dispatch_uid='post_delete_supplier_price_break')
def after_delete_supplier_price(sender, instance, **kwargs):
"""Callback function when a SupplierPriceBreak is deleted"""
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
if instance.part and instance.part.part:
instance.part.part.pricing.schedule_for_update()
+37 -6
View File
@@ -141,7 +141,7 @@ class ManufacturerPartSerializer(InvenTreeModelSerializer):
manufacturer_detail = kwargs.pop('manufacturer_detail', True) manufacturer_detail = kwargs.pop('manufacturer_detail', True)
prettify = kwargs.pop('pretty', False) prettify = kwargs.pop('pretty', False)
super(ManufacturerPartSerializer, self).__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if part_detail is not True: if part_detail is not True:
self.fields.pop('part_detail') self.fields.pop('part_detail')
@@ -205,7 +205,7 @@ class ManufacturerPartParameterSerializer(InvenTreeModelSerializer):
"""Initialize this serializer with extra detail fields as required""" """Initialize this serializer with extra detail fields as required"""
man_detail = kwargs.pop('manufacturer_part_detail', False) man_detail = kwargs.pop('manufacturer_part_detail', False)
super(ManufacturerPartParameterSerializer, self).__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if not man_detail: if not man_detail:
self.fields.pop('manufacturer_part_detail') self.fields.pop('manufacturer_part_detail')
@@ -247,13 +247,17 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
# Check if 'available' quantity was supplied # Check if 'available' quantity was supplied
self.has_available_quantity = 'available' in kwargs.get('data', {}) self.has_available_quantity = 'available' in kwargs.get('data', {})
part_detail = kwargs.pop('part_detail', True) brief = kwargs.pop('brief', False)
supplier_detail = kwargs.pop('supplier_detail', True)
manufacturer_detail = kwargs.pop('manufacturer_detail', True) detail_default = not brief
part_detail = kwargs.pop('part_detail', detail_default)
supplier_detail = kwargs.pop('supplier_detail', detail_default)
manufacturer_detail = kwargs.pop('manufacturer_detail', detail_default)
prettify = kwargs.pop('pretty', False) prettify = kwargs.pop('pretty', False)
super(SupplierPartSerializer, self).__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if part_detail is not True: if part_detail is not True:
self.fields.pop('part_detail') self.fields.pop('part_detail')
@@ -263,6 +267,7 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
if manufacturer_detail is not True: if manufacturer_detail is not True:
self.fields.pop('manufacturer_detail') self.fields.pop('manufacturer_detail')
self.fields.pop('manufacturer_part_detail')
if prettify is not True: if prettify is not True:
self.fields.pop('pretty_name') self.fields.pop('pretty_name')
@@ -294,6 +299,7 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
'MPN', 'MPN',
'note', 'note',
'pk', 'pk',
'barcode_hash',
'packaging', 'packaging',
'pack_size', 'pack_size',
'part', 'part',
@@ -307,6 +313,7 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
read_only_fields = [ read_only_fields = [
'availability_updated', 'availability_updated',
'barcode_hash',
] ]
@staticmethod @staticmethod
@@ -364,6 +371,20 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
class SupplierPriceBreakSerializer(InvenTreeModelSerializer): class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
"""Serializer for SupplierPriceBreak object.""" """Serializer for SupplierPriceBreak object."""
def __init__(self, *args, **kwargs):
"""Initialize this serializer with extra fields as required"""
supplier_detail = kwargs.pop('supplier_detail', False)
part_detail = kwargs.pop('part_detail', False)
super().__init__(*args, **kwargs)
if not supplier_detail:
self.fields.pop('supplier_detail')
if not part_detail:
self.fields.pop('part_detail')
quantity = InvenTreeDecimalField() quantity = InvenTreeDecimalField()
price = InvenTreeMoneySerializer( price = InvenTreeMoneySerializer(
@@ -378,6 +399,13 @@ class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
label=_('Currency'), label=_('Currency'),
) )
supplier = serializers.PrimaryKeyRelatedField(source='part.supplier', many=False, read_only=True)
supplier_detail = CompanyBriefSerializer(source='part.supplier', many=False, read_only=True)
# Detail serializer for SupplierPart
part_detail = SupplierPartSerializer(source='part', brief=True, many=False, read_only=True)
class Meta: class Meta:
"""Metaclass options.""" """Metaclass options."""
@@ -385,8 +413,11 @@ class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
fields = [ fields = [
'pk', 'pk',
'part', 'part',
'part_detail',
'quantity', 'quantity',
'price', 'price',
'price_currency', 'price_currency',
'supplier',
'supplier_detail',
'updated', 'updated',
] ]
@@ -252,6 +252,9 @@ src="{% static 'img/blank_image.png' %}"
</div> </div>
<div class='panel-content'> <div class='panel-content'>
<div id='price-break-toolbar' class='btn-group'> <div id='price-break-toolbar' class='btn-group'>
<div class='btn-group' role='group'>
{% include "filter_list.html" with id='supplierpricebreak' %}
</div>
</div> </div>
<table class='table table-striped table-condensed' id='price-break-table' data-toolbar='#price-break-toolbar'> <table class='table table-striped table-condensed' id='price-break-table' data-toolbar='#price-break-toolbar'>
@@ -291,82 +294,8 @@ $("#barcode-unlink").click(function() {
}); });
{% endif %} {% endif %}
function reloadPriceBreaks() { loadSupplierPriceBreakTable({
$("#price-break-table").bootstrapTable("refresh"); part: {{ part.pk }}
}
$('#price-break-table').inventreeTable({
name: 'buypricebreaks',
formatNoMatches: function() { return "{% trans "No price break information found" %}"; },
queryParams: {
part: {{ part.id }},
},
url: "{% url 'api-part-supplier-price-list' %}",
onPostBody: function() {
var table = $('#price-break-table');
table.find('.button-price-break-delete').click(function() {
var pk = $(this).attr('pk');
constructForm(`/api/company/price-break/${pk}/`, {
method: 'DELETE',
onSuccess: reloadPriceBreaks,
title: '{% trans "Delete Price Break" %}',
});
});
table.find('.button-price-break-edit').click(function() {
var pk = $(this).attr('pk');
constructForm(`/api/company/price-break/${pk}/`, {
fields: {
quantity: {},
price: {},
price_currency: {},
},
onSuccess: reloadPriceBreaks,
title: '{% trans "Edit Price Break" %}',
});
});
},
columns: [
{
field: 'pk',
title: 'ID',
visible: false,
switchable: false,
},
{
field: 'quantity',
title: '{% trans "Quantity" %}',
sortable: true,
},
{
field: 'price',
title: '{% trans "Price" %}',
sortable: true,
formatter: function(value, row, index) {
var html = value;
html += `<div class='btn-group float-right' role='group'>`
html += makeIconButton('fa-edit icon-blue', 'button-price-break-edit', row.pk, '{% trans "Edit price break" %}');
html += makeIconButton('fa-trash-alt icon-red', 'button-price-break-delete', row.pk, '{% trans "Delete price break" %}');
html += `</div>`;
return html;
}
},
{
field: 'updated',
title: '{% trans "Last updated" %}',
sortable: true,
formatter: function(value) {
return renderDate(value);
}
},
]
}); });
$('#new-price-break').click(function() { $('#new-price-break').click(function() {
@@ -386,7 +315,9 @@ $('#new-price-break').click(function() {
}, },
}, },
title: '{% trans "Add Price Break" %}', title: '{% trans "Add Price Break" %}',
onSuccess: reloadPriceBreaks, onSuccess: function() {
$("#price-break-table").bootstrapTable("refresh");
}
} }
); );
}); });
-6
View File
@@ -239,12 +239,6 @@ class ManufacturerTest(InvenTreeAPITestCase):
# Check link is not modified # Check link is not modified
self.assertEqual(response.data['link'], 'https://www.axel-larsson.se/Exego.aspx?p_id=341&ArtNr=0804020E') self.assertEqual(response.data['link'], 'https://www.axel-larsson.se/Exego.aspx?p_id=341&ArtNr=0804020E')
# Check manufacturer part
manufacturer_part_id = int(response.data['manufacturer_part_detail']['pk'])
url = reverse('api-manufacturer-part-detail', kwargs={'pk': manufacturer_part_id})
response = self.get(url)
self.assertEqual(response.data['MPN'], 'PART_NUMBER')
# Check link is not modified # Check link is not modified
self.assertEqual(response.data['link'], 'https://www.axel-larsson.se/Exego.aspx?p_id=341&ArtNr=0804020E') self.assertEqual(response.data['link'], 'https://www.axel-larsson.se/Exego.aspx?p_id=341&ArtNr=0804020E')
+2 -2
View File
@@ -252,7 +252,7 @@ class StockItemLabel(LabelTemplate):
'barcode_data': stock_item.barcode_data, 'barcode_data': stock_item.barcode_data,
'barcode_hash': stock_item.barcode_hash, 'barcode_hash': stock_item.barcode_hash,
'qr_data': stock_item.format_barcode(brief=True), 'qr_data': stock_item.format_barcode(brief=True),
'qr_url': stock_item.format_barcode(url=True, request=request), 'qr_url': request.build_absolute_uri(stock_item.get_absolute_url()),
'tests': stock_item.testResultMap(), 'tests': stock_item.testResultMap(),
'parameters': stock_item.part.parameters_map(), 'parameters': stock_item.part.parameters_map(),
@@ -318,6 +318,6 @@ class PartLabel(LabelTemplate):
'IPN': part.IPN, 'IPN': part.IPN,
'revision': part.revision, 'revision': part.revision,
'qr_data': part.format_barcode(brief=True), 'qr_data': part.format_barcode(brief=True),
'qr_url': part.format_barcode(url=True, request=request), 'qr_url': request.build_absolute_uri(part.get_absolute_url()),
'parameters': part.parameters_map(), 'parameters': part.parameters_map(),
} }
+56
View File
@@ -1,10 +1,14 @@
"""Tests for labels""" """Tests for labels"""
import io
from django.apps import apps from django.apps import apps
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.urls import reverse from django.urls import reverse
from common.models import InvenTreeSetting
from InvenTree.api_tester import InvenTreeAPITestCase from InvenTree.api_tester import InvenTreeAPITestCase
from InvenTree.helpers import validateFilterString from InvenTree.helpers import validateFilterString
from part.models import Part from part.models import Part
@@ -73,3 +77,55 @@ class LabelTest(InvenTreeAPITestCase):
for label in labels: for label in labels:
url = reverse('api-part-label-print', kwargs={'pk': label.pk}) url = reverse('api-part-label-print', kwargs={'pk': label.pk})
self.get(f'{url}?parts={part.pk}', expected_code=200) self.get(f'{url}?parts={part.pk}', expected_code=200)
def test_print_part_label(self):
"""Actually 'print' a label, and ensure that the correct information is contained."""
label_data = """
{% load barcode %}
{% load report %}
<html>
<!-- Test that the part instance is supplied -->
part: {{ part.pk }} - {{ part.name }}
<!-- Test qr data -->
data: {{ qr_data|safe }}
<!-- Test InvenTree URL -->
url: {{ qr_url|safe }}
<!-- Test image URL generation -->
image: {% part_image part %}
<!-- Test InvenTree logo -->
logo: {% logo_image %}
</html>
"""
buffer = io.StringIO()
buffer.write(label_data)
template = ContentFile(buffer.getvalue(), "label.html")
# Construct a label template
label = PartLabel.objects.create(
name='test',
description='Test label',
enabled=True,
label=template,
)
# Ensure we are in "debug" mode (so the report is generated as HTML)
InvenTreeSetting.set_setting('REPORT_ENABLE', True, None)
InvenTreeSetting.set_setting('REPORT_DEBUG_MODE', True, None)
# Print via the API
url = reverse('api-part-label-print', kwargs={'pk': label.pk})
response = self.get(f'{url}?parts=1', expected_code=200)
content = str(response.content)
# Test that each element has been rendered correctly
self.assertIn("part: 1 - M2x4 LPHS", content)
self.assertIn('data: {"part": 1}', content)
self.assertIn("http://testserver/part/1/", content)
self.assertIn("image: /static/img/blank_image.png", content)
self.assertIn("logo: /static/img/inventree.png", content)
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
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
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
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
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
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
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
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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+31 -5
View File
@@ -445,6 +445,19 @@ class PurchaseOrderLineItemFilter(rest_filters.FilterSet):
return queryset return queryset
has_pricing = rest_filters.BooleanFilter(label="Has Pricing", method='filter_has_pricing')
def filter_has_pricing(self, queryset, name, value):
"""Filter by whether or not the line item has pricing information"""
value = str2bool(value)
if value:
queryset = queryset.exclude(purchase_price=None)
else:
queryset = queryset.filter(purchase_price=None)
return queryset
class PurchaseOrderLineItemList(APIDownloadMixin, ListCreateAPI): class PurchaseOrderLineItemList(APIDownloadMixin, ListCreateAPI):
"""API endpoint for accessing a list of PurchaseOrderLineItem objects. """API endpoint for accessing a list of PurchaseOrderLineItem objects.
@@ -776,6 +789,22 @@ class SalesOrderLineItemFilter(rest_filters.FilterSet):
'part', 'part',
] ]
has_pricing = rest_filters.BooleanFilter(label="Has Pricing", method='filter_has_pricing')
def filter_has_pricing(self, queryset, name, value):
"""Filter by whether or not the line item has pricing information"""
value = str2bool(value)
if value:
queryset = queryset.exclude(sale_price=None)
else:
queryset = queryset.filter(sale_price=None)
return queryset
order_status = rest_filters.NumberFilter(label='Order Status', field_name='order__status')
completed = rest_filters.BooleanFilter(label='completed', method='filter_completed') completed = rest_filters.BooleanFilter(label='completed', method='filter_completed')
def filter_completed(self, queryset, name, value): def filter_completed(self, queryset, name, value):
@@ -810,6 +839,8 @@ class SalesOrderLineItemList(ListCreateAPI):
kwargs['part_detail'] = str2bool(params.get('part_detail', False)) kwargs['part_detail'] = str2bool(params.get('part_detail', False))
kwargs['order_detail'] = str2bool(params.get('order_detail', False)) kwargs['order_detail'] = str2bool(params.get('order_detail', False))
kwargs['allocations'] = str2bool(params.get('allocations', False)) kwargs['allocations'] = str2bool(params.get('allocations', False))
kwargs['customer_detail'] = str2bool(params.get('customer_detail', False))
except AttributeError: except AttributeError:
pass pass
@@ -853,11 +884,6 @@ class SalesOrderLineItemList(ListCreateAPI):
'reference', 'reference',
] ]
filterset_fields = [
'order',
'part',
]
class SalesOrderExtraLineList(GeneralExtraLineList, ListCreateAPI): class SalesOrderExtraLineList(GeneralExtraLineList, ListCreateAPI):
"""API endpoint for accessing a list of SalesOrderExtraLine objects.""" """API endpoint for accessing a list of SalesOrderExtraLine objects."""
@@ -0,0 +1,29 @@
# Generated by Django 3.2.16 on 2022-11-10 01:08
import InvenTree.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('order', '0074_auto_20220709_0108'),
]
operations = [
migrations.AlterField(
model_name='purchaseorder',
name='link',
field=InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external page', verbose_name='Link'),
),
migrations.AlterField(
model_name='salesorder',
name='link',
field=InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external page', verbose_name='Link'),
),
migrations.AlterField(
model_name='salesordershipment',
name='link',
field=InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external page', verbose_name='Link'),
),
]
@@ -0,0 +1,35 @@
# Generated by Django 3.2.16 on 2022-11-11 01:53
import InvenTree.fields
from django.db import migrations
import djmoney.models.validators
class Migration(migrations.Migration):
dependencies = [
('order', '0075_auto_20221110_0108'),
]
operations = [
migrations.AlterField(
model_name='purchaseorderextraline',
name='price',
field=InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Unit price', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Price'),
),
migrations.AlterField(
model_name='purchaseorderlineitem',
name='purchase_price',
field=InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Unit purchase price', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Purchase Price'),
),
migrations.AlterField(
model_name='salesorderextraline',
name='price',
field=InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Unit price', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Price'),
),
migrations.AlterField(
model_name='salesorderlineitem',
name='sale_price',
field=InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Unit sale price', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Sale Price'),
),
]
+30 -7
View File
@@ -24,13 +24,14 @@ from mptt.models import TreeForeignKey
import InvenTree.helpers import InvenTree.helpers
import InvenTree.ready import InvenTree.ready
import InvenTree.tasks
import order.validators import order.validators
from common.notifications import InvenTreeNotificationBodies from common.notifications import InvenTreeNotificationBodies
from common.settings import currency_code_default from common.settings import currency_code_default
from company.models import Company, SupplierPart from company.models import Company, SupplierPart
from InvenTree.exceptions import log_error from InvenTree.exceptions import log_error
from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeNotesField, from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeNotesField,
RoundingDecimalField) InvenTreeURLField, RoundingDecimalField)
from InvenTree.helpers import decimal2string, getSetting, notify_responsible from InvenTree.helpers import decimal2string, getSetting, notify_responsible
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
from InvenTree.status_codes import (PurchaseOrderStatus, SalesOrderStatus, from InvenTree.status_codes import (PurchaseOrderStatus, SalesOrderStatus,
@@ -81,7 +82,7 @@ class Order(MetadataMixin, ReferenceIndexingMixin):
description = models.CharField(max_length=250, verbose_name=_('Description'), help_text=_('Order description')) description = models.CharField(max_length=250, verbose_name=_('Description'), help_text=_('Order description'))
link = models.URLField(blank=True, verbose_name=_('Link'), help_text=_('Link to external page')) link = InvenTreeURLField(blank=True, verbose_name=_('Link'), help_text=_('Link to external page'))
creation_date = models.DateField(blank=True, null=True, verbose_name=_('Creation Date')) creation_date = models.DateField(blank=True, null=True, verbose_name=_('Creation Date'))
@@ -311,6 +312,9 @@ class PurchaseOrder(Order):
reference (str, optional): Reference to item. Defaults to ''. reference (str, optional): Reference to item. Defaults to ''.
purchase_price (optional): Price of item. Defaults to None. purchase_price (optional): Price of item. Defaults to None.
Returns:
The newly created PurchaseOrderLineItem instance
Raises: Raises:
ValidationError: quantity is smaller than 0 ValidationError: quantity is smaller than 0
ValidationError: quantity is not type int ValidationError: quantity is not type int
@@ -338,11 +342,13 @@ class PurchaseOrder(Order):
quantity_new = line.quantity + quantity quantity_new = line.quantity + quantity
line.quantity = quantity_new line.quantity = quantity_new
supplier_price = supplier_part.get_price(quantity_new) supplier_price = supplier_part.get_price(quantity_new)
if line.purchase_price and supplier_price: if line.purchase_price and supplier_price:
line.purchase_price = supplier_price / quantity_new line.purchase_price = supplier_price / quantity_new
line.save() line.save()
return return line
line = PurchaseOrderLineItem( line = PurchaseOrderLineItem(
order=self, order=self,
@@ -354,6 +360,8 @@ class PurchaseOrder(Order):
line.save() line.save()
return line
@transaction.atomic @transaction.atomic
def place_order(self): def place_order(self):
"""Marks the PurchaseOrder as PLACED. """Marks the PurchaseOrder as PLACED.
@@ -376,10 +384,21 @@ class PurchaseOrder(Order):
if self.status == PurchaseOrderStatus.PLACED: if self.status == PurchaseOrderStatus.PLACED:
self.status = PurchaseOrderStatus.COMPLETE self.status = PurchaseOrderStatus.COMPLETE
self.complete_date = datetime.now().date() self.complete_date = datetime.now().date()
self.save() self.save()
# Schedule pricing update for any referenced parts
for line in self.lines.all():
if line.part and line.part.part:
line.part.part.pricing.schedule_for_update()
trigger_event('purchaseorder.completed', id=self.pk) trigger_event('purchaseorder.completed', id=self.pk)
@property
def is_pending(self):
"""Return True if the PurchaseOrder is 'pending'"""
return self.status == PurchaseOrderStatus.PENDING
@property @property
def is_overdue(self): def is_overdue(self):
"""Returns True if this PurchaseOrder is "overdue". """Returns True if this PurchaseOrder is "overdue".
@@ -757,6 +776,10 @@ class SalesOrder(Order):
self.save() self.save()
# Schedule pricing update for any referenced parts
for line in self.lines.all():
line.part.pricing.schedule_for_update()
trigger_event('salesorder.completed', id=self.pk) trigger_event('salesorder.completed', id=self.pk)
return True return True
@@ -946,7 +969,7 @@ class OrderExtraLine(OrderLineItem):
price = InvenTreeModelMoneyField( price = InvenTreeModelMoneyField(
max_digits=19, max_digits=19,
decimal_places=4, decimal_places=6,
null=True, blank=True, null=True, blank=True,
allow_negative=True, allow_negative=True,
verbose_name=_('Price'), verbose_name=_('Price'),
@@ -1026,7 +1049,7 @@ class PurchaseOrderLineItem(OrderLineItem):
purchase_price = InvenTreeModelMoneyField( purchase_price = InvenTreeModelMoneyField(
max_digits=19, max_digits=19,
decimal_places=4, decimal_places=6,
null=True, blank=True, null=True, blank=True,
verbose_name=_('Purchase Price'), verbose_name=_('Purchase Price'),
help_text=_('Unit purchase price'), help_text=_('Unit purchase price'),
@@ -1132,7 +1155,7 @@ class SalesOrderLineItem(OrderLineItem):
sale_price = InvenTreeModelMoneyField( sale_price = InvenTreeModelMoneyField(
max_digits=19, max_digits=19,
decimal_places=4, decimal_places=6,
null=True, blank=True, null=True, blank=True,
verbose_name=_('Sale Price'), verbose_name=_('Sale Price'),
help_text=_('Unit sale price'), help_text=_('Unit sale price'),
@@ -1254,7 +1277,7 @@ class SalesOrderShipment(models.Model):
help_text=_('Reference number for associated invoice'), help_text=_('Reference number for associated invoice'),
) )
link = models.URLField( link = InvenTreeURLField(
blank=True, blank=True,
verbose_name=_('Link'), verbose_name=_('Link'),
help_text=_('Link to external page') help_text=_('Link to external page')
+6 -13
View File
@@ -39,8 +39,6 @@ class AbstractOrderSerializer(serializers.Serializer):
read_only=True, read_only=True,
) )
total_price_string = serializers.CharField(source='get_total_price', read_only=True)
class AbstractExtraLineSerializer(serializers.Serializer): class AbstractExtraLineSerializer(serializers.Serializer):
"""Abstract Serializer for a ExtraLine object.""" """Abstract Serializer for a ExtraLine object."""
@@ -60,8 +58,6 @@ class AbstractExtraLineSerializer(serializers.Serializer):
allow_null=True allow_null=True
) )
price_string = serializers.CharField(source='price', read_only=True)
price_currency = serializers.ChoiceField( price_currency = serializers.ChoiceField(
choices=currency_code_mappings(), choices=currency_code_mappings(),
help_text=_('Price currency'), help_text=_('Price currency'),
@@ -81,7 +77,6 @@ class AbstractExtraLineMeta:
'order_detail', 'order_detail',
'price', 'price',
'price_currency', 'price_currency',
'price_string',
] ]
@@ -164,7 +159,6 @@ class PurchaseOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer)
'target_date', 'target_date',
'notes', 'notes',
'total_price', 'total_price',
'total_price_string',
] ]
read_only_fields = [ read_only_fields = [
@@ -326,8 +320,6 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer):
allow_null=True allow_null=True
) )
purchase_price_string = serializers.CharField(source='purchase_price', read_only=True)
destination_detail = stock.serializers.LocationBriefSerializer(source='get_destination', read_only=True) destination_detail = stock.serializers.LocationBriefSerializer(source='get_destination', read_only=True)
purchase_price_currency = serializers.ChoiceField( purchase_price_currency = serializers.ChoiceField(
@@ -387,7 +379,6 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer):
'received', 'received',
'purchase_price', 'purchase_price',
'purchase_price_currency', 'purchase_price_currency',
'purchase_price_string',
'destination', 'destination',
'destination_detail', 'destination_detail',
'target_date', 'target_date',
@@ -745,7 +736,6 @@ class SalesOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer):
'shipment_date', 'shipment_date',
'target_date', 'target_date',
'total_price', 'total_price',
'total_price_string',
] ]
read_only_fields = [ read_only_fields = [
@@ -870,6 +860,7 @@ class SalesOrderLineItemSerializer(InvenTreeModelSerializer):
part_detail = kwargs.pop('part_detail', False) part_detail = kwargs.pop('part_detail', False)
order_detail = kwargs.pop('order_detail', False) order_detail = kwargs.pop('order_detail', False)
allocations = kwargs.pop('allocations', False) allocations = kwargs.pop('allocations', False)
customer_detail = kwargs.pop('customer_detail', False)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -882,6 +873,10 @@ class SalesOrderLineItemSerializer(InvenTreeModelSerializer):
if allocations is not True: if allocations is not True:
self.fields.pop('allocations') self.fields.pop('allocations')
if customer_detail is not True:
self.fields.pop('customer_detail')
customer_detail = CompanyBriefSerializer(source='order.customer', many=False, read_only=True)
order_detail = SalesOrderSerializer(source='order', many=False, read_only=True) order_detail = SalesOrderSerializer(source='order', many=False, read_only=True)
part_detail = PartBriefSerializer(source='part', many=False, read_only=True) part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True) allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True)
@@ -900,8 +895,6 @@ class SalesOrderLineItemSerializer(InvenTreeModelSerializer):
allow_null=True allow_null=True
) )
sale_price_string = serializers.CharField(source='sale_price', read_only=True)
sale_price_currency = serializers.ChoiceField( sale_price_currency = serializers.ChoiceField(
choices=currency_code_mappings(), choices=currency_code_mappings(),
help_text=_('Sale price currency'), help_text=_('Sale price currency'),
@@ -917,6 +910,7 @@ class SalesOrderLineItemSerializer(InvenTreeModelSerializer):
'allocated', 'allocated',
'allocations', 'allocations',
'available_stock', 'available_stock',
'customer_detail',
'quantity', 'quantity',
'reference', 'reference',
'notes', 'notes',
@@ -927,7 +921,6 @@ class SalesOrderLineItemSerializer(InvenTreeModelSerializer):
'part_detail', 'part_detail',
'sale_price', 'sale_price',
'sale_price_currency', 'sale_price_currency',
'sale_price_string',
'shipped', 'shipped',
'target_date', 'target_date',
] ]
@@ -58,8 +58,8 @@
</ul> </ul>
</div> </div>
{% if order.status == PurchaseOrderStatus.PENDING %} {% if order.status == PurchaseOrderStatus.PENDING %}
<button type='button' class='btn btn-outline-secondary' id='place-order' title='{% trans "Place order" %}'> <button type='button' class='btn btn-primary' id='place-order' title='{% trans "Submit Order" %}'>
<span class='fas fa-paper-plane icon-blue'></span> <span class='fas fa-paper-plane'></span> {% trans "Submit Order" %}
</button> </button>
{% elif order.status == PurchaseOrderStatus.PLACED %} {% elif order.status == PurchaseOrderStatus.PLACED %}
<button type='button' class='btn btn-primary' id='receive-order' title='{% trans "Receive items" %}'> <button type='button' class='btn btn-primary' id='receive-order' title='{% trans "Receive items" %}'>
@@ -5,11 +5,13 @@
{% load i18n %} {% load i18n %}
{% load static %} {% load static %}
{% block sidebar %} {% block sidebar %}
{% include 'order/po_sidebar.html' %} {% include 'order/po_sidebar.html' %}
{% endblock %} {% endblock %}
{% block page_content %} {% block page_content %}
{% settings_value "PURCHASEORDER_EDIT_COMPLETED_ORDERS" as allow_extra_editing %}
<div class='panel panel-hidden' id='panel-order-items'> <div class='panel panel-hidden' id='panel-order-items'>
<div class='panel-heading'> <div class='panel-heading'>
@@ -18,7 +20,7 @@
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
{% if roles.purchase_order.change %} {% if roles.purchase_order.change %}
{% if order.status == PurchaseOrderStatus.PENDING %} {% if order.is_pending or allow_extra_editing %}
<a class='btn btn-primary' href='{% url "po-upload" order.id %}' role='button'> <a class='btn btn-primary' href='{% url "po-upload" order.id %}' role='button'>
<span class='fas fa-file-upload side-icon'></span> {% trans "Upload File" %} <span class='fas fa-file-upload side-icon'></span> {% trans "Upload File" %}
</a> </a>
@@ -49,10 +51,12 @@
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
{% if roles.purchase_order.change %} {% if roles.purchase_order.change %}
{% if order.is_pending or allow_extra_editing %}
<button type='button' class='btn btn-success' id='new-po-extra-line'> <button type='button' class='btn btn-success' id='new-po-extra-line'>
<span class='fas fa-plus-circle'></span> {% trans "Add Extra Line" %} <span class='fas fa-plus-circle'></span> {% trans "Add Extra Line" %}
</button> </button>
{% endif %} {% endif %}
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@@ -209,6 +213,9 @@ loadPurchaseOrderLineItemTable('#po-line-table', {
{% else %} {% else %}
allow_edit: false, allow_edit: false,
{% endif %} {% endif %}
{% if order.status == PurchaseOrderStatus.PENDING %}
pending: true,
{% endif %}
{% if order.status == PurchaseOrderStatus.PLACED and roles.purchase_order.change %} {% if order.status == PurchaseOrderStatus.PLACED and roles.purchase_order.change %}
allow_receive: true, allow_receive: true,
{% else %} {% else %}
@@ -241,6 +248,12 @@ loadPurchaseOrderExtraLineTable(
{ {
order: {{ order.pk }}, order: {{ order.pk }},
status: {{ order.status }}, status: {{ order.status }},
{% if order.is_pending %}
pending: true,
{% endif %}
{% if roles.purchase_order.change %}
allow_edit: true,
{% endif %}
} }
); );
@@ -10,6 +10,7 @@
{% endblock %} {% endblock %}
{% block page_content %} {% block page_content %}
{% settings_value "SALESORDER_EDIT_COMPLETED_ORDERS" as allow_extra_editing %}
<div class='panel panel-hidden' id='panel-order-items'> <div class='panel panel-hidden' id='panel-order-items'>
<div class='panel-heading'> <div class='panel-heading'>
@@ -17,11 +18,13 @@
<h4>{% trans "Sales Order Items" %}</h4> <h4>{% trans "Sales Order Items" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
{% if roles.sales_order.change and order.is_pending %} {% if roles.sales_order.change %}
{% if order.is_pending or allow_extra_editing %}
<button type='button' class='btn btn-success' id='new-so-line'> <button type='button' class='btn btn-success' id='new-so-line'>
<span class='fas fa-plus-circle'></span> {% trans "Add Line Item" %} <span class='fas fa-plus-circle'></span> {% trans "Add Line Item" %}
</button> </button>
{% endif %} {% endif %}
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@@ -43,10 +46,12 @@
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
{% if roles.sales_order.change %} {% if roles.sales_order.change %}
{% if order.is_pending or allow_extra_editing %}
<button type='button' class='btn btn-success' id='new-so-extra-line'> <button type='button' class='btn btn-success' id='new-so-extra-line'>
<span class='fas fa-plus-circle'></span> {% trans "Add Extra Line" %} <span class='fas fa-plus-circle'></span> {% trans "Add Extra Line" %}
</button> </button>
{% endif %} {% endif %}
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@@ -265,6 +270,12 @@
order: {{ order.pk }}, order: {{ order.pk }},
reference: '{{ order.reference }}', reference: '{{ order.reference }}',
status: {{ order.status }}, status: {{ order.status }},
{% if roles.sales_order.change %}
allow_edit: true,
{% endif %}
{% if order.is_pending %}
pending: true,
{% endif %}
} }
); );
@@ -289,6 +300,8 @@
{ {
order: {{ order.pk }}, order: {{ order.pk }},
status: {{ order.status }}, status: {{ order.status }},
{% if roles.sales_order.change %}allow_edit: true,{% endif %}
{% if order.is_pending %}pending: true,{% endif %}
} }
); );
+119 -53
View File
@@ -1,6 +1,7 @@
"""Admin class definitions for the 'part' app""" """Admin class definitions for the 'part' app"""
from django.contrib import admin from django.contrib import admin
from django.utils.translation import gettext_lazy as _
import import_export.widgets as widgets import import_export.widgets as widgets
from import_export.admin import ImportExportModelAdmin from import_export.admin import ImportExportModelAdmin
@@ -15,29 +16,57 @@ from stock.models import StockLocation
class PartResource(InvenTreeResource): class PartResource(InvenTreeResource):
"""Class for managing Part data import/export.""" """Class for managing Part data import/export."""
# ForeignKey fields id = Field(attribute='pk', column_name=_('Part ID'), widget=widgets.IntegerWidget())
category = Field(attribute='category', widget=widgets.ForeignKeyWidget(models.PartCategory)) name = Field(attribute='name', column_name=_('Part Name'), widget=widgets.CharWidget())
description = Field(attribute='description', column_name=_('Part Description'), widget=widgets.CharWidget())
IPN = Field(attribute='IPN', column_name=_('IPN'), widget=widgets.CharWidget())
revision = Field(attribute='revision', column_name=_('Revision'), widget=widgets.CharWidget())
keywords = Field(attribute='keywords', column_name=_('Keywords'), widget=widgets.CharWidget())
link = Field(attribute='link', column_name=_('Link'), widget=widgets.CharWidget())
units = Field(attribute='units', column_name=_('Units'), widget=widgets.CharWidget())
notes = Field(attribute='notes', column_name=_('Notes'))
category = Field(attribute='category', column_name=_('Category ID'), widget=widgets.ForeignKeyWidget(models.PartCategory))
category_name = Field(attribute='category__name', column_name=_('Category Name'), readonly=True)
default_location = Field(attribute='default_location', column_name=_('Default Location ID'), widget=widgets.ForeignKeyWidget(StockLocation))
default_supplier = Field(attribute='default_supplier', column_name=_('Default Supplier ID'), widget=widgets.ForeignKeyWidget(SupplierPart))
variant_of = Field(attribute='variant_of', column_name=('Variant Of'), widget=widgets.ForeignKeyWidget(models.Part))
minimum_stock = Field(attribute='minimum_stock', column_name=_('Minimum Stock'))
default_location = Field(attribute='default_location', widget=widgets.ForeignKeyWidget(StockLocation)) # Part Attributes
active = Field(attribute='active', column_name=_('Active'), widget=widgets.BooleanWidget())
default_supplier = Field(attribute='default_supplier', widget=widgets.ForeignKeyWidget(SupplierPart)) assembly = Field(attribute='assembly', column_name=_('Assembly'), widget=widgets.BooleanWidget())
component = Field(attribute='component', column_name=_('Component'), widget=widgets.BooleanWidget())
category_name = Field(attribute='category__name', readonly=True) purchaseable = Field(attribute='purchaseable', column_name=_('Purchaseable'), widget=widgets.BooleanWidget())
salable = Field(attribute='salable', column_name=_('Salable'), widget=widgets.BooleanWidget())
variant_of = Field(attribute='variant_of', widget=widgets.ForeignKeyWidget(models.Part)) is_template = Field(attribute='is_template', column_name=_('Template'), widget=widgets.BooleanWidget())
trackable = Field(attribute='trackable', column_name=_('Trackable'), widget=widgets.BooleanWidget())
suppliers = Field(attribute='supplier_count', readonly=True) virtual = Field(attribute='virtual', column_name=_('Virtual'), widget=widgets.BooleanWidget())
# Extra calculated meta-data (readonly) # Extra calculated meta-data (readonly)
in_stock = Field(attribute='total_stock', readonly=True, widget=widgets.IntegerWidget()) suppliers = Field(attribute='supplier_count', column_name=_('Suppliers'), readonly=True)
in_stock = Field(attribute='total_stock', column_name=_('In Stock'), readonly=True, widget=widgets.IntegerWidget())
on_order = Field(attribute='on_order', column_name=_('On Order'), readonly=True, widget=widgets.IntegerWidget())
used_in = Field(attribute='used_in_count', column_name=_('Used In'), readonly=True, widget=widgets.IntegerWidget())
allocated = Field(attribute='allocation_count', column_name=_('Allocated'), readonly=True, widget=widgets.IntegerWidget())
building = Field(attribute='quantity_being_built', column_name=_('Building'), readonly=True, widget=widgets.IntegerWidget())
min_cost = Field(attribute='pricing__overall_min', column_name=_('Minimum Cost'), readonly=True)
max_cost = Field(attribute='pricing__overall_max', column_name=_('Maximum Cost'), readonly=True)
on_order = Field(attribute='on_order', readonly=True, widget=widgets.IntegerWidget()) def dehydrate_min_cost(self, part):
"""Render minimum cost value for this Part"""
used_in = Field(attribute='used_in_count', readonly=True, widget=widgets.IntegerWidget()) min_cost = part.pricing.overall_min if part.pricing else None
allocated = Field(attribute='allocation_count', readonly=True, widget=widgets.IntegerWidget()) if min_cost is not None:
return float(min_cost.amount)
building = Field(attribute='quantity_being_built', readonly=True, widget=widgets.IntegerWidget()) def dehydrate_max_cost(self, part):
"""Render maximum cost value for this Part"""
max_cost = part.pricing.overall_max if part.pricing else None
if max_cost is not None:
return float(max_cost.amount)
class Meta: class Meta:
"""Metaclass definition""" """Metaclass definition"""
@@ -48,7 +77,9 @@ class PartResource(InvenTreeResource):
exclude = [ exclude = [
'bom_checksum', 'bom_checked_by', 'bom_checked_date', 'bom_checksum', 'bom_checked_by', 'bom_checked_date',
'lft', 'rght', 'tree_id', 'level', 'lft', 'rght', 'tree_id', 'level',
'image',
'metadata', 'metadata',
'barcode_data', 'barcode_hash',
] ]
def get_queryset(self): def get_queryset(self):
@@ -92,14 +123,30 @@ class PartAdmin(ImportExportModelAdmin):
] ]
class PartPricingAdmin(admin.ModelAdmin):
"""Admin class for PartPricing model"""
list_display = ('part', 'overall_min', 'overall_max')
autcomplete_fields = [
'part',
]
class PartCategoryResource(InvenTreeResource): class PartCategoryResource(InvenTreeResource):
"""Class for managing PartCategory data import/export.""" """Class for managing PartCategory data import/export."""
parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(models.PartCategory)) id = Field(attribute='pk', column_name=_('Category ID'))
name = Field(attribute='name', column_name=_('Category Name'))
description = Field(attribute='description', column_name=_('Description'))
parent = Field(attribute='parent', column_name=_('Parent ID'), widget=widgets.ForeignKeyWidget(models.PartCategory))
parent_name = Field(attribute='parent__name', column_name=_('Parent Name'), readonly=True)
default_location = Field(attribute='default_location', column_name=_('Default Location ID'), widget=widgets.ForeignKeyWidget(StockLocation))
default_keywords = Field(attribute='default_keywords', column_name=_('Keywords'))
pathstring = Field(attribute='pathstring', column_name=_('Category Path'))
parent_name = Field(attribute='parent__name', readonly=True) # Calculated fields
parts = Field(attribute='item_count', column_name=_('Parts'), widget=widgets.IntegerWidget(), readonly=True)
default_location = Field(attribute='default_location', widget=widgets.ForeignKeyWidget(StockLocation))
class Meta: class Meta:
"""Metaclass definition""" """Metaclass definition"""
@@ -112,6 +159,7 @@ class PartCategoryResource(InvenTreeResource):
# Exclude MPTT internal model fields # Exclude MPTT internal model fields
'lft', 'rght', 'tree_id', 'level', 'lft', 'rght', 'tree_id', 'level',
'metadata', 'metadata',
'icon',
] ]
def after_import(self, dataset, result, using_transactions, dry_run, **kwargs): def after_import(self, dataset, result, using_transactions, dry_run, **kwargs):
@@ -160,33 +208,41 @@ class PartTestTemplateAdmin(admin.ModelAdmin):
class BomItemResource(InvenTreeResource): class BomItemResource(InvenTreeResource):
"""Class for managing BomItem data import/export.""" """Class for managing BomItem data import/export."""
level = Field(attribute='level', readonly=True) level = Field(attribute='level', column_name=_('BOM Level'), readonly=True)
bom_id = Field(attribute='pk') bom_id = Field(attribute='pk', column_name=_('BOM Item ID'))
# ID of the parent part # ID of the parent part
parent_part_id = Field(attribute='part', widget=widgets.ForeignKeyWidget(models.Part)) parent_part_id = Field(attribute='part', column_name=_('Parent ID'), widget=widgets.ForeignKeyWidget(models.Part))
parent_part_ipn = Field(attribute='part__IPN', column_name=_('Parent IPN'), readonly=True)
parent_part_name = Field(attribute='part__name', column_name=_('Parent Name'), readonly=True)
part_id = Field(attribute='sub_part', column_name=_('Part ID'), widget=widgets.ForeignKeyWidget(models.Part))
part_ipn = Field(attribute='sub_part__IPN', column_name=_('Part IPN'), readonly=True)
part_name = Field(attribute='sub_part__name', column_name=_('Part Name'), readonly=True)
part_description = Field(attribute='sub_part__description', column_name=_('Description'), readonly=True)
quantity = Field(attribute='quantity', column_name=_('Quantity'))
reference = Field(attribute='reference', column_name=_('Reference'))
note = Field(attribute='note', column_name=_('Note'))
min_cost = Field(attribute='sub_part__pricing__overall_min', column_name=_('Minimum Price'), readonly=True)
max_cost = Field(attribute='sub_part__pricing__overall_max', column_name=_('Maximum Price'), readonly=True)
# IPN of the parent part sub_assembly = Field(attribute='sub_part__assembly', column_name=_('Assembly'), readonly=True)
parent_part_ipn = Field(attribute='part__IPN', readonly=True)
# Name of the parent part def dehydrate_min_cost(self, item):
parent_part_name = Field(attribute='part__name', readonly=True) """Render minimum cost value for the BOM line item"""
# ID of the sub-part min_price = item.sub_part.pricing.overall_min if item.sub_part.pricing else None
part_id = Field(attribute='sub_part', widget=widgets.ForeignKeyWidget(models.Part))
# IPN of the sub-part if min_price is not None:
part_ipn = Field(attribute='sub_part__IPN', readonly=True) return float(min_price.amount) * float(item.quantity)
# Name of the sub-part def dehydrate_max_cost(self, item):
part_name = Field(attribute='sub_part__name', readonly=True) """Render maximum cost value for the BOM line item"""
# Description of the sub-part max_price = item.sub_part.pricing.overall_max if item.sub_part.pricing else None
part_description = Field(attribute='sub_part__description', readonly=True)
# Is the sub-part itself an assembly? if max_price is not None:
sub_assembly = Field(attribute='sub_part__assembly', readonly=True) return float(max_price.amount) * float(item.quantity)
def dehydrate_quantity(self, item): def dehydrate_quantity(self, item):
"""Special consideration for the 'quantity' field on data export. We do not want a spreadsheet full of "1.0000" (we'd rather "1") """Special consideration for the 'quantity' field on data export. We do not want a spreadsheet full of "1.0000" (we'd rather "1")
@@ -197,34 +253,43 @@ class BomItemResource(InvenTreeResource):
def before_export(self, queryset, *args, **kwargs): def before_export(self, queryset, *args, **kwargs):
"""Perform before exporting data""" """Perform before exporting data"""
self.is_importing = kwargs.get('importing', False) self.is_importing = kwargs.get('importing', False)
self.include_pricing = kwargs.pop('include_pricing', False)
def get_fields(self, **kwargs): def get_fields(self, **kwargs):
"""If we are exporting for the purposes of generating a 'bom-import' template, there are some fields which we are not interested in.""" """If we are exporting for the purposes of generating a 'bom-import' template, there are some fields which we are not interested in."""
fields = super().get_fields(**kwargs) fields = super().get_fields(**kwargs)
# If we are not generating an "import" template, is_importing = getattr(self, 'is_importing', False)
# just return the complete list of fields include_pricing = getattr(self, 'include_pricing', False)
if not getattr(self, 'is_importing', False):
return fields
# Otherwise, remove some fields we are not interested in to_remove = []
if is_importing or not include_pricing:
# Remove pricing fields in this instance
to_remove += [
'sub_part__pricing__overall_min',
'sub_part__pricing__overall_max',
]
if is_importing:
to_remove += [
'level',
'pk',
'part',
'part__IPN',
'part__name',
'sub_part__name',
'sub_part__description',
'sub_part__assembly'
]
idx = 0 idx = 0
to_remove = [
'level',
'bom_id',
'parent_part_id',
'parent_part_ipn',
'parent_part_name',
'part_description',
'sub_assembly'
]
while idx < len(fields): while idx < len(fields):
if fields[idx].column_name.lower() in to_remove: if fields[idx].attribute in to_remove:
del fields[idx] del fields[idx]
else: else:
idx += 1 idx += 1
@@ -334,3 +399,4 @@ admin.site.register(models.PartCategoryParameterTemplate, PartCategoryParameterA
admin.site.register(models.PartTestTemplate, PartTestTemplateAdmin) admin.site.register(models.PartTestTemplate, PartTestTemplateAdmin)
admin.site.register(models.PartSellPriceBreak, PartSellPriceBreakAdmin) admin.site.register(models.PartSellPriceBreak, PartSellPriceBreakAdmin)
admin.site.register(models.PartInternalPriceBreak, PartInternalPriceBreakAdmin) admin.site.register(models.PartInternalPriceBreak, PartInternalPriceBreakAdmin)
admin.site.register(models.PartPricing, PartPricingAdmin)
+85 -85
View File
@@ -4,35 +4,32 @@ import functools
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from django.db import transaction from django.db import transaction
from django.db.models import Avg, Count, F, Max, Min, Q from django.db.models import Count, F, Q
from django.http import JsonResponse from django.http import JsonResponse
from django.urls import include, path, re_path from django.urls import include, path, re_path
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_filters import rest_framework as rest_filters from django_filters import rest_framework as rest_filters
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from djmoney.contrib.exchange.exceptions import MissingRate
from djmoney.contrib.exchange.models import convert_money
from djmoney.money import Money
from rest_framework import filters, serializers, status from rest_framework import filters, serializers, status
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from rest_framework.response import Response from rest_framework.response import Response
import order.models import order.models
from build.models import Build, BuildItem from build.models import Build, BuildItem
from common.models import InvenTreeSetting
from company.models import Company, ManufacturerPart, SupplierPart from company.models import Company, ManufacturerPart, SupplierPart
from InvenTree.api import (APIDownloadMixin, AttachmentMixin, from InvenTree.api import (APIDownloadMixin, AttachmentMixin,
ListCreateDestroyAPIView) ListCreateDestroyAPIView)
from InvenTree.filters import InvenTreeOrderingFilter from InvenTree.filters import InvenTreeOrderingFilter
from InvenTree.helpers import (DownloadFile, increment_serial_number, isNull, from InvenTree.helpers import (DownloadFile, increment_serial_number, isNull,
str2bool, str2int) str2bool, str2int)
from InvenTree.mixins import (CreateAPI, ListAPI, ListCreateAPI, RetrieveAPI, from InvenTree.mixins import (CreateAPI, CustomRetrieveUpdateDestroyAPI,
ListAPI, ListCreateAPI, RetrieveAPI,
RetrieveUpdateAPI, RetrieveUpdateDestroyAPI, RetrieveUpdateAPI, RetrieveUpdateDestroyAPI,
UpdateAPI) UpdateAPI)
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus, from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
SalesOrderStatus) SalesOrderStatus)
from part.admin import PartResource from part.admin import PartCategoryResource, PartResource
from plugin.serializers import MetadataSerializer from plugin.serializers import MetadataSerializer
from stock.models import StockItem, StockLocation from stock.models import StockItem, StockLocation
@@ -44,7 +41,7 @@ from .models import (BomItem, BomItemSubstitute, Part, PartAttachment,
PartTestTemplate) PartTestTemplate)
class CategoryList(ListCreateAPI): class CategoryList(APIDownloadMixin, ListCreateAPI):
"""API endpoint for accessing a list of PartCategory objects. """API endpoint for accessing a list of PartCategory objects.
- GET: Return a list of PartCategory objects - GET: Return a list of PartCategory objects
@@ -54,6 +51,15 @@ class CategoryList(ListCreateAPI):
queryset = PartCategory.objects.all() queryset = PartCategory.objects.all()
serializer_class = part_serializers.CategorySerializer serializer_class = part_serializers.CategorySerializer
def download_queryset(self, queryset, export_format):
"""Download the filtered queryset as a data file"""
dataset = PartCategoryResource().export(queryset=queryset)
filedata = dataset.export(export_format)
filename = f"InvenTree_Categories.{export_format}"
return DownloadFile(filedata, filename)
def get_queryset(self, *args, **kwargs): def get_queryset(self, *args, **kwargs):
"""Return an annotated queryset for the CategoryList endpoint""" """Return an annotated queryset for the CategoryList endpoint"""
@@ -179,7 +185,7 @@ class CategoryList(ListCreateAPI):
] ]
class CategoryDetail(RetrieveUpdateDestroyAPI): class CategoryDetail(CustomRetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a single PartCategory object.""" """API endpoint for detail view of a single PartCategory object."""
serializer_class = part_serializers.CategorySerializer serializer_class = part_serializers.CategorySerializer
@@ -218,6 +224,16 @@ class CategoryDetail(RetrieveUpdateDestroyAPI):
return response return response
def destroy(self, request, *args, **kwargs):
"""Delete a Part category instance via the API"""
delete_parts = 'delete_parts' in request.data and request.data['delete_parts'] == '1'
delete_child_categories = 'delete_child_categories' in request.data and request.data['delete_child_categories'] == '1'
return super().destroy(request,
*args,
**dict(kwargs,
delete_parts=delete_parts,
delete_child_categories=delete_child_categories))
class CategoryMetadata(RetrieveUpdateAPI): class CategoryMetadata(RetrieveUpdateAPI):
"""API endpoint for viewing / updating PartCategory metadata.""" """API endpoint for viewing / updating PartCategory metadata."""
@@ -709,6 +725,27 @@ class PartMetadata(RetrieveUpdateAPI):
queryset = Part.objects.all() queryset = Part.objects.all()
class PartPricingDetail(RetrieveUpdateAPI):
"""API endpoint for viewing part pricing data"""
serializer_class = part_serializers.PartPricingSerializer
queryset = Part.objects.all()
def get_object(self):
"""Return the PartPricing object associated with the linked Part"""
part = super().get_object()
return part.pricing
def _get_serializer(self, *args, **kwargs):
"""Return a part pricing serializer object"""
part = self.get_object()
kwargs['instance'] = part.pricing
return self.serializer_class(**kwargs)
class PartSerialNumberDetail(RetrieveAPI): class PartSerialNumberDetail(RetrieveAPI):
"""API endpoint for returning extra serial number information about a particular part.""" """API endpoint for returning extra serial number information about a particular part."""
@@ -1003,6 +1040,23 @@ class PartFilter(rest_filters.FilterSet):
queryset = queryset.filter(id__in=[p.pk for p in bom_parts]) queryset = queryset.filter(id__in=[p.pk for p in bom_parts])
return queryset return queryset
has_pricing = rest_filters.BooleanFilter(label="Has Pricing", method="filter_has_pricing")
def filter_has_pricing(self, queryset, name, value):
"""Filter the queryset based on whether pricing information is available for the sub_part"""
value = str2bool(value)
q_a = Q(pricing_data=None)
q_b = Q(pricing_data__overall_min=None, pricing_data__overall_max=None)
if value:
queryset = queryset.exclude(q_a | q_b)
else:
queryset = queryset.filter(q_a | q_b)
return queryset
is_template = rest_filters.BooleanFilter() is_template = rest_filters.BooleanFilter()
assembly = rest_filters.BooleanFilter() assembly = rest_filters.BooleanFilter()
@@ -1052,7 +1106,7 @@ class PartList(APIDownloadMixin, ListCreateAPI):
# Ensure the request context is passed through # Ensure the request context is passed through
kwargs['context'] = self.get_serializer_context() kwargs['context'] = self.get_serializer_context()
# Pass a list of "starred" parts fo the current user to the serializer # Pass a list of "starred" parts to the current user to the serializer
# We do this to reduce the number of database queries required! # We do this to reduce the number of database queries required!
if self.starred_parts is None and self.request is not None: if self.starred_parts is None and self.request is not None:
self.starred_parts = [star.part for star in self.request.user.starred_parts.all()] self.starred_parts = [star.part for star in self.request.user.starred_parts.all()]
@@ -1469,7 +1523,7 @@ class PartList(APIDownloadMixin, ListCreateAPI):
filter_backends = [ filter_backends = [
DjangoFilterBackend, DjangoFilterBackend,
filters.SearchFilter, filters.SearchFilter,
filters.OrderingFilter, InvenTreeOrderingFilter,
] ]
ordering_fields = [ ordering_fields = [
@@ -1706,6 +1760,23 @@ class BomFilter(rest_filters.FilterSet):
return queryset return queryset
has_pricing = rest_filters.BooleanFilter(label="Has Pricing", method="filter_has_pricing")
def filter_has_pricing(self, queryset, name, value):
"""Filter the queryset based on whether pricing information is available for the sub_part"""
value = str2bool(value)
q_a = Q(sub_part__pricing_data=None)
q_b = Q(sub_part__pricing_data__overall_min=None, sub_part__pricing_data__overall_max=None)
if value:
queryset = queryset.exclude(q_a | q_b)
else:
queryset = queryset.filter(q_a | q_b)
return queryset
class BomList(ListCreateDestroyAPIView): class BomList(ListCreateDestroyAPIView):
"""API endpoint for accessing a list of BomItem objects. """API endpoint for accessing a list of BomItem objects.
@@ -1750,7 +1821,6 @@ class BomList(ListCreateDestroyAPIView):
If requested, extra detail fields are annotated to the queryset: If requested, extra detail fields are annotated to the queryset:
- part_detail - part_detail
- sub_part_detail - sub_part_detail
- include_pricing
""" """
# Do we wish to include extra detail? # Do we wish to include extra detail?
@@ -1764,12 +1834,6 @@ class BomList(ListCreateDestroyAPIView):
except AttributeError: except AttributeError:
pass pass
try:
# Include or exclude pricing information in the serialized data
kwargs['include_pricing'] = self.include_pricing()
except AttributeError:
pass
# Ensure the request context is passed through! # Ensure the request context is passed through!
kwargs['context'] = self.get_serializer_context() kwargs['context'] = self.get_serializer_context()
@@ -1839,73 +1903,6 @@ class BomList(ListCreateDestroyAPIView):
except (ValueError, Part.DoesNotExist): except (ValueError, Part.DoesNotExist):
pass pass
if self.include_pricing():
queryset = self.annotate_pricing(queryset)
return queryset
def include_pricing(self):
"""Determine if pricing information should be included in the response."""
pricing_default = InvenTreeSetting.get_setting('PART_SHOW_PRICE_IN_BOM')
return str2bool(self.request.query_params.get('include_pricing', pricing_default))
def annotate_pricing(self, queryset):
"""Add part pricing information to the queryset."""
# Annotate with purchase prices
queryset = queryset.annotate(
purchase_price_min=Min('sub_part__stock_items__purchase_price'),
purchase_price_max=Max('sub_part__stock_items__purchase_price'),
purchase_price_avg=Avg('sub_part__stock_items__purchase_price'),
)
# Get values for currencies
currencies = queryset.annotate(
purchase_price=F('sub_part__stock_items__purchase_price'),
purchase_price_currency=F('sub_part__stock_items__purchase_price_currency'),
).values('pk', 'sub_part', 'purchase_price', 'purchase_price_currency')
def convert_price(price, currency, decimal_places=4):
"""Convert price field, returns Money field."""
price_adjusted = None
# Get default currency from settings
default_currency = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY')
if price:
if currency and default_currency:
try:
# Get adjusted price
price_adjusted = convert_money(Money(price, currency), default_currency)
except MissingRate:
# No conversion rate set
price_adjusted = Money(price, currency)
else:
# Currency exists
if currency:
price_adjusted = Money(price, currency)
# Default currency exists
if default_currency:
price_adjusted = Money(price, default_currency)
if price_adjusted and decimal_places:
price_adjusted.decimal_places = decimal_places
return price_adjusted
# Convert prices to default currency (using backend conversion rates)
for bom_item in queryset:
# Find associated currency (select first found)
purchase_price_currency = None
for currency_item in currencies:
if currency_item['pk'] == bom_item.pk and currency_item['sub_part'] == bom_item.sub_part.pk and currency_item['purchase_price']:
purchase_price_currency = currency_item['purchase_price_currency']
break
# Convert prices
bom_item.purchase_price_min = convert_price(bom_item.purchase_price_min, purchase_price_currency)
bom_item.purchase_price_max = convert_price(bom_item.purchase_price_max, purchase_price_currency)
bom_item.purchase_price_avg = convert_price(bom_item.purchase_price_avg, purchase_price_currency)
return queryset return queryset
filter_backends = [ filter_backends = [
@@ -2134,6 +2131,9 @@ part_api_urls = [
# Part metadata # Part metadata
re_path(r'^metadata/', PartMetadata.as_view(), name='api-part-metadata'), re_path(r'^metadata/', PartMetadata.as_view(), name='api-part-metadata'),
# Part pricing
re_path(r'^pricing/', PartPricingDetail.as_view(), name='api-part-pricing'),
# Part detail endpoint # Part detail endpoint
re_path(r'^.*$', PartDetail.as_view(), name='api-part-detail'), re_path(r'^.*$', PartDetail.as_view(), name='api-part-detail'),
])), ])),
+23 -1
View File
@@ -5,7 +5,7 @@ import logging
from django.apps import AppConfig from django.apps import AppConfig
from django.db.utils import OperationalError, ProgrammingError from django.db.utils import OperationalError, ProgrammingError
from InvenTree.ready import canAppAccessDatabase from InvenTree.ready import canAppAccessDatabase, isImportingData
logger = logging.getLogger("inventree") logger = logging.getLogger("inventree")
@@ -18,6 +18,7 @@ class PartConfig(AppConfig):
"""This function is called whenever the Part app is loaded.""" """This function is called whenever the Part app is loaded."""
if canAppAccessDatabase(): if canAppAccessDatabase():
self.update_trackable_status() self.update_trackable_status()
self.reset_part_pricing_flags()
def update_trackable_status(self): def update_trackable_status(self):
"""Check for any instances where a trackable part is used in the BOM for a non-trackable part. """Check for any instances where a trackable part is used in the BOM for a non-trackable part.
@@ -37,3 +38,24 @@ class PartConfig(AppConfig):
except (OperationalError, ProgrammingError): # pragma: no cover except (OperationalError, ProgrammingError): # pragma: no cover
# Exception if the database has not been migrated yet # Exception if the database has not been migrated yet
pass pass
def reset_part_pricing_flags(self):
"""Performed on startup, to ensure that all pricing objects are in a "good" state.
Prevents issues with state machine if the server is restarted mid-update
"""
from .models import PartPricing
if isImportingData():
return
items = PartPricing.objects.filter(scheduled_for_update=True)
if items.count() > 0:
# Find any pricing objects which have the 'scheduled_for_update' flag set
print(f"Resetting update flags for {items.count()} pricing objects...")
for pricing in items:
pricing.scheduled_for_update = False
pricing.save()
+18 -3
View File
@@ -8,7 +8,8 @@ from collections import OrderedDict
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from company.models import ManufacturerPart, SupplierPart from company.models import ManufacturerPart, SupplierPart
from InvenTree.helpers import DownloadFile, GetExportFormats, normalize from InvenTree.helpers import (DownloadFile, GetExportFormats, normalize,
str2bool)
from .admin import BomItemResource from .admin import BomItemResource
from .models import BomItem, Part from .models import BomItem, Part
@@ -42,7 +43,7 @@ def MakeBomTemplate(fmt):
return DownloadFile(data, filename) return DownloadFile(data, filename)
def ExportBom(part: Part, fmt='csv', cascade: bool = False, max_levels: int = None, parameter_data=False, stock_data=False, supplier_data=False, manufacturer_data=False): def ExportBom(part: Part, fmt='csv', cascade: bool = False, max_levels: int = None, **kwargs):
"""Export a BOM (Bill of Materials) for a given part. """Export a BOM (Bill of Materials) for a given part.
Args: Args:
@@ -50,14 +51,24 @@ def ExportBom(part: Part, fmt='csv', cascade: bool = False, max_levels: int = No
fmt (str, optional): file format. Defaults to 'csv'. fmt (str, optional): file format. Defaults to 'csv'.
cascade (bool, optional): If True, multi-level BOM output is supported. Otherwise, a flat top-level-only BOM is exported.. Defaults to False. cascade (bool, optional): If True, multi-level BOM output is supported. Otherwise, a flat top-level-only BOM is exported.. Defaults to False.
max_levels (int, optional): Levels of items that should be included. None for np sublevels. Defaults to None. max_levels (int, optional): Levels of items that should be included. None for np sublevels. Defaults to None.
kwargs:
parameter_data (bool, optional): Additonal data that should be added. Defaults to False. parameter_data (bool, optional): Additonal data that should be added. Defaults to False.
stock_data (bool, optional): Additonal data that should be added. Defaults to False. stock_data (bool, optional): Additonal data that should be added. Defaults to False.
supplier_data (bool, optional): Additonal data that should be added. Defaults to False. supplier_data (bool, optional): Additonal data that should be added. Defaults to False.
manufacturer_data (bool, optional): Additonal data that should be added. Defaults to False. manufacturer_data (bool, optional): Additonal data that should be added. Defaults to False.
pricing_data (bool, optional): Include pricing data in exported BOM. Defaults to False
Returns: Returns:
StreamingHttpResponse: Response that can be passed to the endpoint StreamingHttpResponse: Response that can be passed to the endpoint
""" """
parameter_data = str2bool(kwargs.get('parameter_data', False))
stock_data = str2bool(kwargs.get('stock_data', False))
supplier_data = str2bool(kwargs.get('supplier_data', False))
manufacturer_data = str2bool(kwargs.get('manufacturer_data', False))
pricing_data = str2bool(kwargs.get('pricing_data', False))
if not IsValidBOMFormat(fmt): if not IsValidBOMFormat(fmt):
fmt = 'csv' fmt = 'csv'
@@ -85,7 +96,11 @@ def ExportBom(part: Part, fmt='csv', cascade: bool = False, max_levels: int = No
add_items(top_level_items, 1, cascade) add_items(top_level_items, 1, cascade)
dataset = BomItemResource().export(queryset=bom_items, cascade=cascade) dataset = BomItemResource().export(
queryset=bom_items,
cascade=cascade,
include_pricing=pricing_data,
)
def add_columns_to_dataset(columns, column_size): def add_columns_to_dataset(columns, column_size):
try: try:
@@ -0,0 +1,76 @@
# Generated by Django 3.2.16 on 2022-11-12 01:28
import InvenTree.fields
import common.settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import djmoney.models.fields
import djmoney.models.validators
class Migration(migrations.Migration):
dependencies = [
('part', '0088_alter_partparametertemplate_name'),
]
operations = [
migrations.AlterField(
model_name='part',
name='base_cost',
field=models.DecimalField(decimal_places=6, default=0, help_text='Minimum charge (e.g. stocking fee)', max_digits=19, validators=[django.core.validators.MinValueValidator(0)], verbose_name='base cost'),
),
migrations.AlterField(
model_name='partinternalpricebreak',
name='price',
field=InvenTree.fields.InvenTreeModelMoneyField(currency_choices=[], decimal_places=6, default_currency='', help_text='Unit price at specified quantity', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Price'),
),
migrations.AlterField(
model_name='partsellpricebreak',
name='price',
field=InvenTree.fields.InvenTreeModelMoneyField(currency_choices=[], decimal_places=6, default_currency='', help_text='Unit price at specified quantity', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Price'),
),
migrations.CreateModel(
name='PartPricing',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('currency', models.CharField(choices=[('AUD', 'Australian Dollar'), ('CAD', 'Canadian Dollar'), ('CNY', 'Chinese Yuan'), ('EUR', 'Euro'), ('GBP', 'British Pound'), ('JPY', 'Japanese Yen'), ('NZD', 'New Zealand Dollar'), ('USD', 'US Dollar')], default=common.settings.currency_code_default, help_text='Currency used to cache pricing calculations', max_length=10, verbose_name='Currency')),
('updated', models.DateTimeField(auto_now=True, help_text='Timestamp of last pricing update', verbose_name='Updated')),
('scheduled_for_update', models.BooleanField(default=False)),
('bom_cost_min_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('bom_cost_min', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Minimum cost of component parts', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Minimum BOM Cost')),
('bom_cost_max_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('bom_cost_max', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Maximum cost of component parts', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Maximum BOM Cost')),
('purchase_cost_min_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('purchase_cost_min', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Minimum historical purchase cost', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Minimum Purchase Cost')),
('purchase_cost_max_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('purchase_cost_max', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Maximum historical purchase cost', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Maximum Purchase Cost')),
('internal_cost_min_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('internal_cost_min', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Minimum cost based on internal price breaks', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Minimum Internal Price')),
('internal_cost_max_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('internal_cost_max', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Maximum cost based on internal price breaks', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Maximum Internal Price')),
('supplier_price_min_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('supplier_price_min', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Minimum price of part from external suppliers', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Minimum Supplier Price')),
('supplier_price_max_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('supplier_price_max', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Maximum price of part from external suppliers', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Maximum Supplier Price')),
('variant_cost_min_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('variant_cost_min', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Calculated minimum cost of variant parts', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Minimum Variant Cost')),
('variant_cost_max_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('variant_cost_max', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Calculated maximum cost of variant parts', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Maximum Variant Cost')),
('overall_min_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('overall_min', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Calculated overall minimum cost', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Minimum Cost')),
('overall_max_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('overall_max', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Calculated overall maximum cost', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Maximum Cost')),
('sale_price_min_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('sale_price_min', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Minimum sale price based on price breaks', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Minimum Sale Price')),
('sale_price_max_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('sale_price_max', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Maximum sale price based on price breaks', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Maximum Sale Price')),
('sale_history_min_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('sale_history_min', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Minimum historical sale price', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Minimum Sale Cost')),
('sale_history_max_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('sale_history_max', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Maximum historical sale price', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Maximum Sale Cost')),
('part', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='pricing_data', to='part.part', verbose_name='Part')),
],
),
]
+669 -28
View File
@@ -15,7 +15,7 @@ from django.core.validators import MinValueValidator
from django.db import models, transaction from django.db import models, transaction
from django.db.models import ExpressionWrapper, F, Q, Sum, UniqueConstraint from django.db.models import ExpressionWrapper, F, Q, Sum, UniqueConstraint
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.db.models.signals import post_save from django.db.models.signals import post_delete, post_save
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
from django.dispatch import receiver from django.dispatch import receiver
from django.urls import reverse from django.urls import reverse
@@ -24,6 +24,7 @@ from django.utils.translation import gettext_lazy as _
from django_cleanup import cleanup from django_cleanup import cleanup
from djmoney.contrib.exchange.exceptions import MissingRate from djmoney.contrib.exchange.exceptions import MissingRate
from djmoney.contrib.exchange.models import convert_money from djmoney.contrib.exchange.models import convert_money
from djmoney.money import Money
from jinja2 import Template from jinja2 import Template
from mptt.exceptions import InvalidMove from mptt.exceptions import InvalidMove
from mptt.managers import TreeManager from mptt.managers import TreeManager
@@ -31,6 +32,8 @@ from mptt.models import MPTTModel, TreeForeignKey
from stdimage.models import StdImageField from stdimage.models import StdImageField
import common.models import common.models
import common.settings
import InvenTree.fields
import InvenTree.ready import InvenTree.ready
import InvenTree.tasks import InvenTree.tasks
import part.filters as part_filters import part.filters as part_filters
@@ -63,31 +66,46 @@ class PartCategory(MetadataMixin, InvenTreeTree):
default_keywords: Default keywords for parts created in this category default_keywords: Default keywords for parts created in this category
""" """
def delete_recursive(self, *args, **kwargs):
"""This function handles the recursive deletion of subcategories depending on kwargs contents"""
delete_parts = kwargs.get('delete_parts', False)
parent_category = kwargs.get('parent_category', None)
if parent_category is None:
# First iteration, (no part_category kwargs passed)
parent_category = self.parent
for child_part in self.parts.all():
if delete_parts:
child_part.delete()
else:
child_part.category = parent_category
child_part.save()
for child_category in self.children.all():
if kwargs.get('delete_child_categories', False):
child_category.delete_recursive(**dict(delete_child_categories=True,
delete_parts=delete_parts,
parent_category=parent_category))
else:
child_category.parent = parent_category
child_category.save()
super().delete(*args, **dict())
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
"""Custom model deletion routine, which updates any child categories or parts. """Custom model deletion routine, which updates any child categories or parts.
This must be handled within a transaction.atomic(), otherwise the tree structure is damaged This must be handled within a transaction.atomic(), otherwise the tree structure is damaged
""" """
with transaction.atomic(): with transaction.atomic():
self.delete_recursive(**dict(delete_parts=kwargs.get('delete_parts', False),
delete_child_categories=kwargs.get('delete_child_categories', False),
parent_category=self.parent))
parent = self.parent if self.parent is not None:
tree_id = self.tree_id
# Update each part in this category to point to the parent category
for p in self.parts.all():
p.category = self.parent
p.save()
# Update each child category
for child in self.children.all():
child.parent = self.parent
child.save()
super().delete(*args, **kwargs)
if parent is not None:
# Partially rebuild the tree (cheaper than a complete rebuild) # Partially rebuild the tree (cheaper than a complete rebuild)
PartCategory.objects.partial_rebuild(tree_id) PartCategory.objects.partial_rebuild(self.tree_id)
else: else:
PartCategory.objects.rebuild() PartCategory.objects.rebuild()
@@ -293,6 +311,7 @@ class PartManager(TreeManager):
"""Perform default prefetch operations when accessing Part model from the database""" """Perform default prefetch operations when accessing Part model from the database"""
return super().get_queryset().prefetch_related( return super().get_queryset().prefetch_related(
'category', 'category',
'pricing_data',
'category__parent', 'category__parent',
'stock_items', 'stock_items',
'builds', 'builds',
@@ -1634,15 +1653,25 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
"""Return the number of supplier parts available for this part.""" """Return the number of supplier parts available for this part."""
return self.supplier_parts.count() return self.supplier_parts.count()
@property def update_pricing(self):
def has_complete_bom_pricing(self): """Recalculate cached pricing for this Part instance"""
"""Return true if there is pricing information for each item in the BOM."""
use_internal = common.models.InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False)
for item in self.get_bom_items().select_related('sub_part'):
if item.sub_part.get_price_range(internal=use_internal) is None:
return False
return True self.pricing.update_pricing()
@property
def pricing(self):
"""Return the PartPricing information for this Part instance.
If there is no PartPricing database entry defined for this Part,
it will first be created, and then returned.
"""
try:
pricing = PartPricing.objects.get(part=self)
except PartPricing.DoesNotExist:
pricing = PartPricing(part=self)
return pricing
def get_price_info(self, quantity=1, buy=True, bom=True, internal=False): def get_price_info(self, quantity=1, buy=True, bom=True, internal=False):
"""Return a simplified pricing string for this part. """Return a simplified pricing string for this part.
@@ -1785,7 +1814,7 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
max(buy_price_range[1], bom_price_range[1]) max(buy_price_range[1], bom_price_range[1])
) )
base_cost = models.DecimalField(max_digits=10, decimal_places=3, default=0, validators=[MinValueValidator(0)], verbose_name=_('base cost'), help_text=_('Minimum charge (e.g. stocking fee)')) base_cost = models.DecimalField(max_digits=19, decimal_places=6, default=0, validators=[MinValueValidator(0)], verbose_name=_('base cost'), help_text=_('Minimum charge (e.g. stocking fee)'))
multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], verbose_name=_('multiple'), help_text=_('Sell multiple')) multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], verbose_name=_('multiple'), help_text=_('Sell multiple'))
@@ -2184,6 +2213,596 @@ def after_save_part(sender, instance: Part, created, **kwargs):
InvenTree.tasks.offload_task(part_tasks.notify_low_stock_if_required, instance) InvenTree.tasks.offload_task(part_tasks.notify_low_stock_if_required, instance)
class PartPricing(models.Model):
"""Model for caching min/max pricing information for a particular Part
It is prohibitively expensive to calculate min/max pricing for a part "on the fly".
As min/max pricing does not change very often, we pre-calculate and cache these values.
Whenever pricing is updated, these values are re-calculated and stored.
Pricing information is cached for:
- BOM cost (min / max cost of component items)
- Purchase cost (based on purchase history)
- Internal cost (based on user-specified InternalPriceBreak data)
- Supplier price (based on supplier part data)
- Variant price (min / max cost of any variants)
- Overall best / worst (based on the values listed above)
- Sale price break min / max values
- Historical sale pricing min / max values
Note that this pricing information does not take "quantity" into account:
- This provides a simple min / max pricing range, which is quite valuable in a lot of situations
- Quantity pricing still needs to be calculated
- Quantity pricing can be viewed from the part detail page
- Detailed pricing information is very context specific in any case
"""
@property
def is_valid(self):
"""Return True if the cached pricing is valid"""
return self.updated is not None
def convert(self, money):
"""Attempt to convert money value to default currency.
If a MissingRate error is raised, ignore it and return None
"""
if money is None:
return None
target_currency = currency_code_default()
try:
result = convert_money(money, target_currency)
except MissingRate:
logger.warning(f"No currency conversion rate available for {money.currency} -> {target_currency}")
result = None
return result
def schedule_for_update(self, counter: int = 0):
"""Schedule this pricing to be updated"""
if self.pk is None:
self.save()
self.refresh_from_db()
if self.scheduled_for_update:
# Ignore if the pricing is already scheduled to be updated
logger.info(f"Pricing for {self.part} already scheduled for update - skipping")
return
if counter > 25:
# Prevent infinite recursion / stack depth issues
logger.info(counter, f"Skipping pricing update for {self.part} - maximum depth exceeded")
return
self.scheduled_for_update = True
self.save()
import part.tasks as part_tasks
# Offload task to update the pricing
# Force async, to prevent running in the foreground
InvenTree.tasks.offload_task(
part_tasks.update_part_pricing,
self,
counter=counter,
force_async=True
)
def update_pricing(self, counter: int = 0):
"""Recalculate all cost data for the referenced Part instance"""
if self.pk is not None:
self.refresh_from_db()
self.update_bom_cost(save=False)
self.update_purchase_cost(save=False)
self.update_internal_cost(save=False)
self.update_supplier_cost(save=False)
self.update_variant_cost(save=False)
self.update_sale_cost(save=False)
# Clear scheduling flag
self.scheduled_for_update = False
# Note: save method calls update_overall_cost
try:
self.save()
except IntegrityError:
# Background worker processes may try to concurrently update
pass
# Update parent assemblies and templates
self.update_assemblies(counter)
self.update_templates(counter)
def update_assemblies(self, counter: int = 0):
"""Schedule updates for any assemblies which use this part"""
# If the linked Part is used in any assemblies, schedule a pricing update for those assemblies
used_in_parts = self.part.get_used_in()
for p in used_in_parts:
p.pricing.schedule_for_update(counter + 1)
def update_templates(self, counter: int = 0):
"""Schedule updates for any template parts above this part"""
templates = self.part.get_ancestors(include_self=False)
for p in templates:
p.pricing.schedule_for_update(counter + 1)
def save(self, *args, **kwargs):
"""Whenever pricing model is saved, automatically update overall prices"""
# Update the currency which was used to perform the calculation
self.currency = currency_code_default()
self.update_overall_cost()
super().save(*args, **kwargs)
def update_bom_cost(self, save=True):
"""Recalculate BOM cost for the referenced Part instance.
Iterate through the Bill of Materials, and calculate cumulative pricing:
cumulative_min: The sum of minimum costs for each line in the BOM
cumulative_max: The sum of maximum costs for each line in the BOM
Note: The cumulative costs are calculated based on the specified default currency
"""
if not self.part.assembly:
# Not an assembly - no BOM pricing
self.bom_cost_min = None
self.bom_cost_max = None
if save:
self.save()
# Short circuit - no further operations required
return
currency_code = common.settings.currency_code_default()
cumulative_min = Money(0, currency_code)
cumulative_max = Money(0, currency_code)
any_min_elements = False
any_max_elements = False
for bom_item in self.part.get_bom_items():
# Loop through each BOM item which is used to assemble this part
bom_item_min = None
bom_item_max = None
for sub_part in bom_item.get_valid_parts_for_allocation():
# Check each part which *could* be used
sub_part_pricing = sub_part.pricing
sub_part_min = self.convert(sub_part_pricing.overall_min)
sub_part_max = self.convert(sub_part_pricing.overall_max)
if sub_part_min is not None:
if bom_item_min is None or sub_part_min < bom_item_min:
bom_item_min = sub_part_min
if sub_part_max is not None:
if bom_item_max is None or sub_part_max > bom_item_max:
bom_item_max = sub_part_max
# Update cumulative totals
if bom_item_min is not None:
bom_item_min *= bom_item.quantity
cumulative_min += self.convert(bom_item_min)
any_min_elements = True
if bom_item_max is not None:
bom_item_max *= bom_item.quantity
cumulative_max += self.convert(bom_item_max)
any_max_elements = True
if any_min_elements:
self.bom_cost_min = cumulative_min
else:
self.bom_cost_min = None
if any_max_elements:
self.bom_cost_max = cumulative_max
else:
self.bom_cost_max = None
if save:
self.save()
def update_purchase_cost(self, save=True):
"""Recalculate historical purchase cost for the referenced Part instance.
Purchase history only takes into account "completed" purchase orders.
"""
# Find all line items for completed orders which reference this part
line_items = OrderModels.PurchaseOrderLineItem.objects.filter(
order__status=PurchaseOrderStatus.COMPLETE,
received__gt=0,
part__part=self.part,
)
# Exclude line items which do not have an associated price
line_items = line_items.exclude(purchase_price=None)
purchase_min = None
purchase_max = None
for line in line_items:
if line.purchase_price is None:
continue
# Take supplier part pack size into account
purchase_cost = self.convert(line.purchase_price / line.part.pack_size)
if purchase_cost is None:
continue
if purchase_min is None or purchase_cost < purchase_min:
purchase_min = purchase_cost
if purchase_max is None or purchase_cost > purchase_max:
purchase_max = purchase_cost
self.purchase_cost_min = purchase_min
self.purchase_cost_max = purchase_max
if save:
self.save()
def update_internal_cost(self, save=True):
"""Recalculate internal cost for the referenced Part instance"""
min_int_cost = None
max_int_cost = None
if InvenTreeSetting.get_setting('PART_INTERNAL_PRICE', False, cache=False):
# Only calculate internal pricing if internal pricing is enabled
for pb in self.part.internalpricebreaks.all():
cost = self.convert(pb.price)
if cost is None:
# Ignore if cost could not be converted for some reason
continue
if min_int_cost is None or cost < min_int_cost:
min_int_cost = cost
if max_int_cost is None or cost > max_int_cost:
max_int_cost = cost
self.internal_cost_min = min_int_cost
self.internal_cost_max = max_int_cost
if save:
self.save()
def update_supplier_cost(self, save=True):
"""Recalculate supplier cost for the referenced Part instance.
- The limits are simply the lower and upper bounds of available SupplierPriceBreaks
- We do not take "quantity" into account here
"""
min_sup_cost = None
max_sup_cost = None
if self.part.purchaseable:
# Iterate through each available SupplierPart instance
for sp in self.part.supplier_parts.all():
# Iterate through each available SupplierPriceBreak instance
for pb in sp.pricebreaks.all():
if pb.price is None:
continue
# Ensure we take supplier part pack size into account
cost = self.convert(pb.price / sp.pack_size)
if cost is None:
continue
if min_sup_cost is None or cost < min_sup_cost:
min_sup_cost = cost
if max_sup_cost is None or cost > max_sup_cost:
max_sup_cost = cost
self.supplier_price_min = min_sup_cost
self.supplier_price_max = max_sup_cost
if save:
self.save()
def update_variant_cost(self, save=True):
"""Update variant cost values.
Here we track the min/max costs of any variant parts.
"""
variant_min = None
variant_max = None
if self.part.is_template:
variants = self.part.get_descendants(include_self=False)
for v in variants:
v_min = self.convert(v.pricing.overall_min)
v_max = self.convert(v.pricing.overall_max)
if v_min is not None:
if variant_min is None or v_min < variant_min:
variant_min = v_min
if v_max is not None:
if variant_max is None or v_max > variant_max:
variant_max = v_max
self.variant_cost_min = variant_min
self.variant_cost_max = variant_max
if save:
self.save()
def update_overall_cost(self):
"""Update overall cost values.
Here we simply take the minimum / maximum values of the other calculated fields.
"""
overall_min = None
overall_max = None
min_costs = [
self.bom_cost_min,
self.purchase_cost_min,
self.internal_cost_min,
self.variant_cost_min
]
max_costs = [
self.bom_cost_max,
self.purchase_cost_max,
self.internal_cost_max,
self.variant_cost_max
]
if InvenTreeSetting.get_setting('PRICING_USE_SUPPLIER_PRICING', True):
min_costs.append(self.supplier_price_min)
max_costs.append(self.supplier_price_max)
# Calculate overall minimum cost
for cost in min_costs:
if cost is None:
continue
# Ensure we are working in a common currency
cost = self.convert(cost)
if overall_min is None or cost < overall_min:
overall_min = cost
# Calculate overall maximum cost
for cost in max_costs:
if cost is None:
continue
# Ensure we are working in a common currency
cost = self.convert(cost)
if overall_max is None or cost > overall_max:
overall_max = cost
if InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False, cache=False):
# Check if internal pricing should override other pricing
if self.internal_cost_min is not None:
overall_min = self.internal_cost_min
if self.internal_cost_max is not None:
overall_max = self.internal_cost_max
self.overall_min = overall_min
self.overall_max = overall_max
def update_sale_cost(self, save=True):
"""Recalculate sale cost data"""
# Iterate through the sell price breaks
min_sell_price = None
max_sell_price = None
for pb in self.part.salepricebreaks.all():
cost = self.convert(pb.price)
if cost is None:
continue
if min_sell_price is None or cost < min_sell_price:
min_sell_price = cost
if max_sell_price is None or cost > max_sell_price:
max_sell_price = cost
# Record min/max values
self.sale_price_min = min_sell_price
self.sale_price_max = max_sell_price
min_sell_history = None
max_sell_history = None
# Find all line items for shipped sales orders which reference this part
line_items = OrderModels.SalesOrderLineItem.objects.filter(
order__status=SalesOrderStatus.SHIPPED,
part=self.part
)
# Exclude line items which do not have associated pricing data
line_items = line_items.exclude(sale_price=None)
for line in line_items:
cost = self.convert(line.sale_price)
if cost is None:
continue
if min_sell_history is None or cost < min_sell_history:
min_sell_history = cost
if max_sell_history is None or cost > max_sell_history:
max_sell_history = cost
self.sale_history_min = min_sell_history
self.sale_history_max = max_sell_history
if save:
self.save()
currency = models.CharField(
default=currency_code_default,
max_length=10,
verbose_name=_('Currency'),
help_text=_('Currency used to cache pricing calculations'),
choices=common.settings.currency_code_mappings(),
)
updated = models.DateTimeField(
verbose_name=_('Updated'),
help_text=_('Timestamp of last pricing update'),
auto_now=True
)
scheduled_for_update = models.BooleanField(
default=False,
)
part = models.OneToOneField(
Part,
on_delete=models.CASCADE,
related_name='pricing_data',
verbose_name=_('Part'),
)
bom_cost_min = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Minimum BOM Cost'),
help_text=_('Minimum cost of component parts')
)
bom_cost_max = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Maximum BOM Cost'),
help_text=_('Maximum cost of component parts'),
)
purchase_cost_min = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Minimum Purchase Cost'),
help_text=_('Minimum historical purchase cost'),
)
purchase_cost_max = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Maximum Purchase Cost'),
help_text=_('Maximum historical purchase cost'),
)
internal_cost_min = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Minimum Internal Price'),
help_text=_('Minimum cost based on internal price breaks'),
)
internal_cost_max = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Maximum Internal Price'),
help_text=_('Maximum cost based on internal price breaks'),
)
supplier_price_min = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Minimum Supplier Price'),
help_text=_('Minimum price of part from external suppliers'),
)
supplier_price_max = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Maximum Supplier Price'),
help_text=_('Maximum price of part from external suppliers'),
)
variant_cost_min = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Minimum Variant Cost'),
help_text=_('Calculated minimum cost of variant parts'),
)
variant_cost_max = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Maximum Variant Cost'),
help_text=_('Calculated maximum cost of variant parts'),
)
overall_min = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Minimum Cost'),
help_text=_('Calculated overall minimum cost'),
)
overall_max = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Maximum Cost'),
help_text=_('Calculated overall maximum cost'),
)
sale_price_min = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Minimum Sale Price'),
help_text=_('Minimum sale price based on price breaks'),
)
sale_price_max = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Maximum Sale Price'),
help_text=_('Maximum sale price based on price breaks'),
)
sale_history_min = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Minimum Sale Cost'),
help_text=_('Minimum historical sale price'),
)
sale_history_max = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Maximum Sale Cost'),
help_text=_('Maximum historical sale price'),
)
class PartAttachment(InvenTreeAttachment): class PartAttachment(InvenTreeAttachment):
"""Model for storing file attachments against a Part object.""" """Model for storing file attachments against a Part object."""
@@ -2871,7 +3490,7 @@ class BomItem(DataImportMixin, models.Model):
def price_range(self, internal=False): def price_range(self, internal=False):
"""Return the price-range for this BOM item.""" """Return the price-range for this BOM item."""
# get internal price setting # get internal price setting
use_internal = common.models.InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False) use_internal = common.models.InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False, cache=False)
prange = self.sub_part.get_price_range(self.quantity, internal=use_internal and internal) prange = self.sub_part.get_price_range(self.quantity, internal=use_internal and internal)
if prange is None: if prange is None:
@@ -2889,6 +3508,28 @@ class BomItem(DataImportMixin, models.Model):
return "{pmin} to {pmax}".format(pmin=pmin, pmax=pmax) return "{pmin} to {pmax}".format(pmin=pmin, pmax=pmax)
@receiver(post_save, sender=BomItem, dispatch_uid='post_save_bom_item')
@receiver(post_save, sender=PartSellPriceBreak, dispatch_uid='post_save_sale_price_break')
@receiver(post_save, sender=PartInternalPriceBreak, dispatch_uid='post_save_internal_price_break')
def update_pricing_after_edit(sender, instance, created, **kwargs):
"""Callback function when a part price break is created or updated"""
# Update part pricing *unless* we are importing data
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
instance.part.pricing.schedule_for_update()
@receiver(post_delete, sender=BomItem, dispatch_uid='post_delete_bom_item')
@receiver(post_delete, sender=PartSellPriceBreak, dispatch_uid='post_delete_sale_price_break')
@receiver(post_delete, sender=PartInternalPriceBreak, dispatch_uid='post_delete_internal_price_break')
def update_pricing_after_delete(sender, instance, **kwargs):
"""Callback function when a part price break is deleted"""
# Update part pricing *unless* we are importing data
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
instance.part.pricing.schedule_for_update()
class BomItemSubstitute(models.Model): class BomItemSubstitute(models.Model):
"""A BomItemSubstitute provides a specification for alternative parts, which can be used in a bill of materials. """A BomItemSubstitute provides a specification for alternative parts, which can be used in a bill of materials.
+103 -79
View File
@@ -11,10 +11,10 @@ from django.db.models.functions import Coalesce
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from djmoney.contrib.django_rest_framework import MoneyField
from rest_framework import serializers from rest_framework import serializers
from sql_util.utils import SubqueryCount, SubquerySum from sql_util.utils import SubqueryCount, SubquerySum
import InvenTree.helpers
import part.filters import part.filters
from common.settings import currency_code_default, currency_code_mappings from common.settings import currency_code_default, currency_code_mappings
from InvenTree.serializers import (DataFileExtractSerializer, from InvenTree.serializers import (DataFileExtractSerializer,
@@ -30,8 +30,8 @@ from InvenTree.status_codes import BuildStatus
from .models import (BomItem, BomItemSubstitute, Part, PartAttachment, from .models import (BomItem, BomItemSubstitute, Part, PartAttachment,
PartCategory, PartCategoryParameterTemplate, PartCategory, PartCategoryParameterTemplate,
PartInternalPriceBreak, PartParameter, PartInternalPriceBreak, PartParameter,
PartParameterTemplate, PartRelated, PartSellPriceBreak, PartParameterTemplate, PartPricing, PartRelated,
PartStar, PartTestTemplate) PartSellPriceBreak, PartStar, PartTestTemplate)
class CategorySerializer(InvenTreeModelSerializer): class CategorySerializer(InvenTreeModelSerializer):
@@ -154,8 +154,6 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
help_text=_('Purchase currency of this stock item'), help_text=_('Purchase currency of this stock item'),
) )
price_string = serializers.CharField(source='price', read_only=True)
class Meta: class Meta:
"""Metaclass defining serializer fields""" """Metaclass defining serializer fields"""
model = PartSellPriceBreak model = PartSellPriceBreak
@@ -165,7 +163,6 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
'quantity', 'quantity',
'price', 'price',
'price_currency', 'price_currency',
'price_string',
] ]
@@ -185,8 +182,6 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
help_text=_('Purchase currency of this stock item'), help_text=_('Purchase currency of this stock item'),
) )
price_string = serializers.CharField(source='price', read_only=True)
class Meta: class Meta:
"""Metaclass defining serializer fields""" """Metaclass defining serializer fields"""
model = PartInternalPriceBreak model = PartInternalPriceBreak
@@ -196,7 +191,6 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
'quantity', 'quantity',
'price', 'price',
'price_currency', 'price_currency',
'price_string',
] ]
@@ -285,6 +279,7 @@ class PartBriefSerializer(InvenTreeModelSerializer):
fields = [ fields = [
'pk', 'pk',
'IPN', 'IPN',
'barcode_hash',
'default_location', 'default_location',
'name', 'name',
'revision', 'revision',
@@ -301,6 +296,10 @@ class PartBriefSerializer(InvenTreeModelSerializer):
'units', 'units',
] ]
read_only_fields = [
'barcode_hash',
]
class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer): class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
"""Serializer for complete detail information of a part. """Serializer for complete detail information of a part.
@@ -416,6 +415,10 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
# PrimaryKeyRelated fields (Note: enforcing field type here results in much faster queries, somehow...) # PrimaryKeyRelated fields (Note: enforcing field type here results in much faster queries, somehow...)
category = serializers.PrimaryKeyRelatedField(queryset=PartCategory.objects.all()) category = serializers.PrimaryKeyRelatedField(queryset=PartCategory.objects.all())
# Pricing fields
pricing_min = InvenTreeMoneySerializer(source='pricing_data.overall_min', allow_null=True, read_only=True)
pricing_max = InvenTreeMoneySerializer(source='pricing_data.overall_max', allow_null=True, read_only=True)
parameters = PartParameterSerializer( parameters = PartParameterSerializer(
many=True, many=True,
read_only=True, read_only=True,
@@ -430,6 +433,7 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
'allocated_to_build_orders', 'allocated_to_build_orders',
'allocated_to_sales_orders', 'allocated_to_sales_orders',
'assembly', 'assembly',
'barcode_hash',
'category', 'category',
'category_detail', 'category_detail',
'component', 'component',
@@ -465,6 +469,12 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
'units', 'units',
'variant_of', 'variant_of',
'virtual', 'virtual',
'pricing_min',
'pricing_max',
]
read_only_fields = [
'barcode_hash',
] ]
def save(self): def save(self):
@@ -493,6 +503,84 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
return self.instance return self.instance
class PartPricingSerializer(InvenTreeModelSerializer):
"""Serializer for Part pricing information"""
currency = serializers.CharField(allow_null=True, read_only=True)
updated = serializers.DateTimeField(allow_null=True, read_only=True)
scheduled_for_update = serializers.BooleanField(read_only=True)
# Custom serializers
bom_cost_min = InvenTreeMoneySerializer(allow_null=True, read_only=True)
bom_cost_max = InvenTreeMoneySerializer(allow_null=True, read_only=True)
purchase_cost_min = InvenTreeMoneySerializer(allow_null=True, read_only=True)
purchase_cost_max = InvenTreeMoneySerializer(allow_null=True, read_only=True)
internal_cost_min = InvenTreeMoneySerializer(allow_null=True, read_only=True)
internal_cost_max = InvenTreeMoneySerializer(allow_null=True, read_only=True)
supplier_price_min = InvenTreeMoneySerializer(allow_null=True, read_only=True)
supplier_price_max = InvenTreeMoneySerializer(allow_null=True, read_only=True)
variant_cost_min = InvenTreeMoneySerializer(allow_null=True, read_only=True)
variant_cost_max = InvenTreeMoneySerializer(allow_null=True, read_only=True)
overall_min = InvenTreeMoneySerializer(allow_null=True, read_only=True)
overall_max = InvenTreeMoneySerializer(allow_null=True, read_only=True)
sale_price_min = InvenTreeMoneySerializer(allow_null=True, read_only=True)
sale_price_max = InvenTreeMoneySerializer(allow_null=True, read_only=True)
sale_history_min = InvenTreeMoneySerializer(allow_null=True, read_only=True)
sale_history_max = InvenTreeMoneySerializer(allow_null=True, read_only=True)
update = serializers.BooleanField(
write_only=True,
label=_('Update'),
help_text=_('Update pricing for this part'),
default=False,
required=False,
)
class Meta:
"""Metaclass defining serializer fields"""
model = PartPricing
fields = [
'currency',
'updated',
'scheduled_for_update',
'bom_cost_min',
'bom_cost_max',
'purchase_cost_min',
'purchase_cost_max',
'internal_cost_min',
'internal_cost_max',
'supplier_price_min',
'supplier_price_max',
'variant_cost_min',
'variant_cost_max',
'overall_min',
'overall_max',
'sale_price_min',
'sale_price_max',
'sale_history_min',
'sale_history_max',
'update',
]
def save(self):
"""Called when the serializer is saved"""
data = self.validated_data
if InvenTree.helpers.str2bool(data.get('update', False)):
# Update part pricing
pricing = self.instance
pricing.update_pricing()
class PartRelationSerializer(InvenTreeModelSerializer): class PartRelationSerializer(InvenTreeModelSerializer):
"""Serializer for a PartRelated model.""" """Serializer for a PartRelated model."""
@@ -548,8 +636,6 @@ class BomItemSubstituteSerializer(InvenTreeModelSerializer):
class BomItemSerializer(InvenTreeModelSerializer): class BomItemSerializer(InvenTreeModelSerializer):
"""Serializer for BomItem object.""" """Serializer for BomItem object."""
price_range = serializers.CharField(read_only=True)
quantity = InvenTreeDecimalField(required=True) quantity = InvenTreeDecimalField(required=True)
def validate_quantity(self, quantity): def validate_quantity(self, quantity):
@@ -571,16 +657,12 @@ class BomItemSerializer(InvenTreeModelSerializer):
validated = serializers.BooleanField(read_only=True, source='is_line_valid') validated = serializers.BooleanField(read_only=True, source='is_line_valid')
purchase_price_min = MoneyField(max_digits=19, decimal_places=4, read_only=True)
purchase_price_max = MoneyField(max_digits=19, decimal_places=4, read_only=True)
purchase_price_avg = serializers.SerializerMethodField()
purchase_price_range = serializers.SerializerMethodField()
on_order = serializers.FloatField(read_only=True) on_order = serializers.FloatField(read_only=True)
# Cached pricing fields
pricing_min = InvenTreeMoneySerializer(source='sub_part.pricing.overall_min', allow_null=True, read_only=True)
pricing_max = InvenTreeMoneySerializer(source='sub_part.pricing.overall_max', allow_null=True, read_only=True)
# Annotated fields for available stock # Annotated fields for available stock
available_stock = serializers.FloatField(read_only=True) available_stock = serializers.FloatField(read_only=True)
available_substitute_stock = serializers.FloatField(read_only=True) available_substitute_stock = serializers.FloatField(read_only=True)
@@ -594,7 +676,6 @@ class BomItemSerializer(InvenTreeModelSerializer):
""" """
part_detail = kwargs.pop('part_detail', False) part_detail = kwargs.pop('part_detail', False)
sub_part_detail = kwargs.pop('sub_part_detail', False) sub_part_detail = kwargs.pop('sub_part_detail', False)
include_pricing = kwargs.pop('include_pricing', False)
super(BomItemSerializer, self).__init__(*args, **kwargs) super(BomItemSerializer, self).__init__(*args, **kwargs)
@@ -604,14 +685,6 @@ class BomItemSerializer(InvenTreeModelSerializer):
if sub_part_detail is not True: if sub_part_detail is not True:
self.fields.pop('sub_part_detail') self.fields.pop('sub_part_detail')
if not include_pricing:
# Remove all pricing related fields
self.fields.pop('price_range')
self.fields.pop('purchase_price_min')
self.fields.pop('purchase_price_max')
self.fields.pop('purchase_price_avg')
self.fields.pop('purchase_price_range')
@staticmethod @staticmethod
def setup_eager_loading(queryset): def setup_eager_loading(queryset):
"""Prefetch against the provided queryset to speed up database access""" """Prefetch against the provided queryset to speed up database access"""
@@ -633,7 +706,6 @@ class BomItemSerializer(InvenTreeModelSerializer):
'substitutes__part__stock_items', 'substitutes__part__stock_items',
) )
queryset = queryset.prefetch_related('sub_part__supplier_parts__pricebreaks')
return queryset return queryset
@staticmethod @staticmethod
@@ -707,51 +779,6 @@ class BomItemSerializer(InvenTreeModelSerializer):
return queryset return queryset
def get_purchase_price_range(self, obj):
"""Return purchase price range."""
try:
purchase_price_min = obj.purchase_price_min
except AttributeError:
return None
try:
purchase_price_max = obj.purchase_price_max
except AttributeError:
return None
if purchase_price_min and not purchase_price_max:
# Get price range
purchase_price_range = str(purchase_price_max)
elif not purchase_price_min and purchase_price_max:
# Get price range
purchase_price_range = str(purchase_price_max)
elif purchase_price_min and purchase_price_max:
# Get price range
if purchase_price_min >= purchase_price_max:
# If min > max: use min only
purchase_price_range = str(purchase_price_min)
else:
purchase_price_range = str(purchase_price_min) + " - " + str(purchase_price_max)
else:
purchase_price_range = '-'
return purchase_price_range
def get_purchase_price_avg(self, obj):
"""Return purchase price average."""
try:
purchase_price_avg = obj.purchase_price_avg
except AttributeError:
return None
if purchase_price_avg:
# Get string representation of price average
purchase_price_avg = str(purchase_price_avg)
else:
purchase_price_avg = '-'
return purchase_price_avg
class Meta: class Meta:
"""Metaclass defining serializer fields""" """Metaclass defining serializer fields"""
model = BomItem model = BomItem
@@ -765,16 +792,13 @@ class BomItemSerializer(InvenTreeModelSerializer):
'pk', 'pk',
'part', 'part',
'part_detail', 'part_detail',
'purchase_price_avg', 'pricing_min',
'purchase_price_max', 'pricing_max',
'purchase_price_min',
'purchase_price_range',
'quantity', 'quantity',
'reference', 'reference',
'sub_part', 'sub_part',
'sub_part_detail', 'sub_part_detail',
'substitutes', 'substitutes',
'price_range',
'validated', 'validated',
# Annotated fields describing available quantity # Annotated fields describing available quantity
+71
View File
@@ -1,13 +1,17 @@
"""Background task definitions for the 'part' app""" """Background task definitions for the 'part' app"""
import logging import logging
from datetime import datetime, timedelta
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import common.models
import common.notifications import common.notifications
import common.settings
import InvenTree.helpers import InvenTree.helpers
import InvenTree.tasks import InvenTree.tasks
import part.models import part.models
from InvenTree.tasks import ScheduledTask, scheduled_task
logger = logging.getLogger("inventree") logger = logging.getLogger("inventree")
@@ -53,3 +57,70 @@ def notify_low_stock_if_required(part: part.models.Part):
notify_low_stock, notify_low_stock,
p p
) )
def update_part_pricing(pricing: part.models.PartPricing, counter: int = 0):
"""Update cached pricing data for the specified PartPricing instance
Arguments:
pricing: The target PartPricing instance to be updated
counter: How many times this function has been called in sequence
"""
logger.info(f"Updating part pricing for {pricing.part}")
pricing.update_pricing(counter=counter)
@scheduled_task(ScheduledTask.DAILY)
def check_missing_pricing(limit=250):
"""Check for parts with missing or outdated pricing information:
- Pricing information does not exist
- Pricing information is "old"
- Pricing information is in the wrong currency
Arguments:
limit: Maximum number of parts to process at once
"""
# Find parts for which pricing information has never been updated
results = part.models.PartPricing.objects.filter(updated=None)[:limit]
if results.count() > 0:
logger.info(f"Found {results.count()} parts with empty pricing")
for pp in results:
pp.schedule_for_update()
# Find any parts which have 'old' pricing information
days = int(common.models.InvenTreeSetting.get_setting('PRICING_UPDATE_DAYS', 30))
stale_date = datetime.now().date() - timedelta(days=days)
results = part.models.PartPricing.objects.filter(updated__lte=stale_date)[:limit]
if results.count() > 0:
logger.info(f"Found {results.count()} stale pricing entries")
for pp in results:
pp.schedule_for_update()
# Find any pricing data which is in the wrong currency
currency = common.settings.currency_code_default()
results = part.models.PartPricing.objects.exclude(currency=currency)
if results.count() > 0:
logger.info(f"Found {results.count()} pricing entries in the wrong currency")
for pp in results:
pp.schedule_for_update()
# Find any parts which do not have pricing information
results = part.models.Part.objects.filter(pricing_data=None)[:limit]
if results.count() > 0:
logger.info(f"Found {results.count()} parts without pricing")
for p in results:
pricing = p.pricing
pricing.schedule_for_update()
+1 -1
View File
@@ -25,7 +25,7 @@
{% endblock %} {% endblock %}
{% block actions %} {% block actions %}
{% if user.is_staff and roles.part_category.change %} {% if category and user.is_staff and roles.part_category.change %}
{% url 'admin:part_partcategory_change' category.pk as url %} {% url 'admin:part_partcategory_change' category.pk as url %}
{% include "admin_button.html" with url=url %} {% include "admin_button.html" with url=url %}
{% endif %} {% endif %}
+1 -158
View File
@@ -131,11 +131,9 @@
</div> </div>
</div> </div>
{% if part.purchaseable or part.salable %}
<div class='panel panel-hidden' id='panel-pricing'> <div class='panel panel-hidden' id='panel-pricing'>
{% include "part/prices.html" %} {% include "part/prices.html" %}
</div> </div>
{% endif %}
<div class='panel panel-hidden' id='panel-part-notes'> <div class='panel panel-hidden' id='panel-part-notes'>
<div class='panel-heading'> <div class='panel-heading'>
@@ -878,162 +876,7 @@
}); });
onPanelLoad('pricing', function() { onPanelLoad('pricing', function() {
{% default_currency as currency %} {% include "part/pricing_javascript.html" %}
// Load the BOM table data in the pricing view
{% if part.has_bom and roles.sales_order.view %}
loadBomTable($("#bom-pricing-table"), {
editable: false,
bom_url: "{% url 'api-bom-list' %}",
part_url: "{% url 'api-part-list' %}",
parent_id: {{ part.id }} ,
sub_part_detail: true,
});
{% endif %}
// history graphs
{% if price_history %}
var purchasepricedata = {
labels: [
{% for line in price_history %}'{% render_date line.date %}',{% endfor %}
],
datasets: [{
label: '{% blocktrans %}Purchase Unit Price - {{currency}}{% endblocktrans %}',
backgroundColor: 'rgba(255, 99, 132, 0.2)',
borderColor: 'rgb(255, 99, 132)',
yAxisID: 'y',
data: [
{% for line in price_history %}{{ line.price|stringformat:".2f" }},{% endfor %}
],
borderWidth: 1,
type: 'line'
},
{% if 'price_diff' in price_history.0 %}
{
label: '{% blocktrans %}Unit Price-Cost Difference - {{ currency }}{% endblocktrans %}',
backgroundColor: 'rgba(68, 157, 68, 0.2)',
borderColor: 'rgb(68, 157, 68)',
yAxisID: 'y2',
data: [
{% for line in price_history %}{{ line.price_diff|stringformat:".2f" }},{% endfor %}
],
borderWidth: 1,
type: 'line',
hidden: true,
},
{
label: '{% blocktrans %}Supplier Unit Cost - {{currency}}{% endblocktrans %}',
backgroundColor: 'rgba(70, 127, 155, 0.2)',
borderColor: 'rgb(70, 127, 155)',
yAxisID: 'y',
data: [
{% for line in price_history %}{{ line.price_part|stringformat:".2f" }},{% endfor %}
],
borderWidth: 1,
type: 'line',
hidden: true,
},
{% endif %}
{
label: '{% trans "Quantity" %}',
backgroundColor: 'rgba(255, 206, 86, 0.2)',
borderColor: 'rgb(255, 206, 86)',
yAxisID: 'y1',
data: [
{% for line in price_history %}{{ line.qty|stringformat:"f" }},{% endfor %}
],
borderWidth: 1
}]
}
var StockPriceChart = loadStockPricingChart($('#StockPriceChart'), purchasepricedata)
{% endif %}
{% if bom_parts %}
var bom_colors = randomColor({hue: 'green', count: {{ bom_parts|length }} })
var bomdata = {
labels: [{% for line in bom_parts %}'{{ line.name|escapejs }}',{% endfor %}],
datasets: [
{
label: 'Price',
data: [{% for line in bom_parts %}{{ line.min_price }},{% endfor %}],
backgroundColor: bom_colors,
},
{% if bom_pie_max %}
{
label: 'Max Price',
data: [{% for line in bom_parts %}{{ line.max_price }},{% endfor %}],
backgroundColor: bom_colors,
},
{% endif %}
]
};
var BomChart = loadBomChart(document.getElementById('BomChart'), bomdata)
{% endif %}
// Internal pricebreaks
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
{% if show_internal_price and roles.sales_order.view %}
initPriceBreakSet(
$('#internal-price-break-table'),
{
part_id: {{part.id}},
pb_human_name: 'internal price break',
pb_url_slug: 'internal-price',
pb_url: '{% url 'api-part-internal-price-list' %}',
pb_new_btn: $('#new-internal-price-break'),
pb_new_url: '{% url 'api-part-internal-price-list' %}',
linkedGraph: $('#InternalPriceBreakChart'),
},
);
{% endif %}
// Sales pricebreaks
{% if part.salable and roles.sales_order.view %}
initPriceBreakSet(
$('#price-break-table'),
{
part_id: {{part.id}},
pb_human_name: 'sale price break',
pb_url_slug: 'sale-price',
pb_url: "{% url 'api-part-sale-price-list' %}",
pb_new_btn: $('#new-price-break'),
pb_new_url: '{% url 'api-part-sale-price-list' %}',
linkedGraph: $('#SalePriceBreakChart'),
},
);
{% endif %}
// Sale price history
{% if sale_history %}
var salepricedata = {
labels: [
{% for line in sale_history %}'{% render_date line.date %}',{% endfor %}
],
datasets: [{
label: '{% blocktrans %}Unit Price - {{currency}}{% endblocktrans %}',
backgroundColor: 'rgba(255, 99, 132, 0.2)',
borderColor: 'rgb(255, 99, 132)',
yAxisID: 'y',
data: [
{% for line in sale_history %}{{ line.price|stringformat:".2f" }},{% endfor %}
],
borderWidth: 1,
},
{
label: '{% trans "Quantity" %}',
backgroundColor: 'rgba(255, 206, 86, 0.2)',
borderColor: 'rgb(255, 206, 86)',
yAxisID: 'y1',
data: [
{% for line in sale_history %}{{ line.qty|stringformat:"f" }},{% endfor %}
],
borderWidth: 1,
type: 'bar',
}]
}
var SalePriceChart = loadSellPricingChart($('#SalePriceChart'), salepricedata)
{% endif %}
}); });
enableSidebar('part'); enableSidebar('part');
@@ -323,6 +323,21 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
{% with part.pricing as pricing %}
{% if pricing.is_valid %}
<tr>
<td><span class='fas fa-dollar-sign'></span></td>
<td>{% trans "Price Range" %}</td>
<td>
{% if pricing.overall_min == pricing.overall_max %}
{% render_currency pricing.overall_max %}
{% else %}
{% render_currency pricing.overall_min %} - {% render_currency pricing.overall_max %}
{% endif %}
</td>
</tr>
{% endif %}
{% endwith %}
{% with part.get_latest_serial_number as sn %} {% with part.get_latest_serial_number as sn %}
{% if part.trackable and sn %} {% if part.trackable and sn %}
<tr> <tr>
@@ -76,14 +76,6 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if not part.has_complete_bom_pricing %}
<tr>
<td colspan='3'>
<span class='warning-msg'><em>{% trans 'Note: BOM pricing is incomplete for this part' %}</em></span>
</td>
</tr>
{% endif %}
{% if min_total_bom_price or min_total_bom_purchase_price %} {% if min_total_bom_price or min_total_bom_purchase_price %}
{% else %} {% else %}
<tr> <tr>
@@ -27,10 +27,8 @@
{% trans "Used In" as text %} {% trans "Used In" as text %}
{% include "sidebar_item.html" with label="used-in" text=text icon="fa-layer-group" %} {% include "sidebar_item.html" with label="used-in" text=text icon="fa-layer-group" %}
{% endif %} {% endif %}
{% if part.purchaseable or part.salable %}
{% trans "Pricing" as text %} {% trans "Pricing" as text %}
{% include "sidebar_item.html" with label="pricing" text=text icon="fa-dollar-sign" %} {% include "sidebar_item.html" with label="pricing" text=text icon="fa-dollar-sign" %}
{% endif %}
{% if part.purchaseable and roles.purchase_order.view %} {% if part.purchaseable and roles.purchase_order.view %}
{% trans "Suppliers" as text %} {% trans "Suppliers" as text %}
{% include "sidebar_item.html" with label="suppliers" text=text icon="fa-building" %} {% include "sidebar_item.html" with label="suppliers" text=text icon="fa-building" %}
+277 -228
View File
@@ -5,252 +5,299 @@
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %} {% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
{% if show_price_history %}
<div class='panel-heading'>
<h4>{% trans "Pricing Information" %}</h4>
</div>
{% default_currency as currency %}
<div class='panel-content'>
<div class="row">
<a class="anchor" id="overview"></a> <a class="anchor" id="overview"></a>
<div class="col col-md-6"> <div class='panel-heading'>
<h4>{% trans "Pricing ranges" %}</h4> <div class='d-flex flex-wrap'>
<h4>{% trans "Pricing Overview" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
<button type='button' class='btn btn-success' id='part-pricing-refresh' title='{% trans "Refresh Part Pricing" %}'>
<span class='fas fa-redo-alt'></span> {% trans "Refresh" %}
</button>
</div>
</div>
</div>
<div class='panel-content'>
{% with part.pricing as pricing %}
{% if pricing.is_valid %}
<!-- Part pricing table -->
<div class='alert alert-info alert-block'>
{% trans "Last Updated" %}: {% render_date pricing.updated %}
</div>
<div class='row full-height'>
<div class='col col-md-6'>
<table class='table table-striped table-condensed'> <table class='table table-striped table-condensed'>
{% if part.supplier_count > 0 %} <col width='25'>
{% if min_total_buy_price %} <thead>
<tr> <tr>
<td><strong>{% trans 'Supplier Pricing' %}</strong> <th></th>
<a href="#supplier-cost" title='{% trans "Show supplier cost" %}'><span class="fas fa-search-dollar"></span></a> <th>{% trans "Price Category" %}</th>
<a href="#purchase-price" title='{% trans "Show purchase price" %}'><span class="fas fa-chart-bar"></span></a> <th>{% trans "Minimum" %}</th>
</td> <th>{% trans "Maximum" %}</th>
<td>{% trans 'Unit Cost' %}</td>
<td>Min: {% include "price.html" with price=min_unit_buy_price %}</td>
<td>Max: {% include "price.html" with price=max_unit_buy_price %}</td>
</tr> </tr>
{% if quantity > 1 %} </thead>
<tbody>
<tr> <tr>
<td></td> <td>
<td>{% trans 'Total Cost' %}</td>
<td>Min: {% include "price.html" with price=min_total_buy_price %}</td>
<td>Max: {% include "price.html" with price=max_total_buy_price %}</td>
</tr>
{% endif %}
{% else %}
<tr>
<td colspan='4'>
<span class='warning-msg'><em>{% trans 'No supplier pricing available' %}</em></span>
</td>
</tr>
{% endif %}
{% endif %}
{% if part.assembly and part.bom_count > 0 %}
{% if min_total_bom_price %}
<tr>
<td><strong>{% trans 'BOM Pricing' %}</strong>
<a href="#bom-cost" title='{% trans "Show BOM cost" %}'><span class="fas fa-search-dollar"></span></a>
</td>
<td>{% trans 'Unit Cost' %}</td>
<td>Min: {% include "price.html" with price=min_unit_bom_price %}</td>
<td>Max: {% include "price.html" with price=max_unit_bom_price %}</td>
</tr>
{% if quantity > 1 %}
<tr>
<td></td>
<td>{% trans 'Total Cost' %}</td>
<td>Min: {% include "price.html" with price=min_total_bom_price %}</td>
<td>Max: {% include "price.html" with price=max_total_bom_price %}</td>
</tr>
{% endif %}
{% endif %}
{% if min_total_bom_purchase_price %}
<tr>
<td></td>
<td>{% trans 'Unit Purchase Price' %}</td>
<td>Min: {% include "price.html" with price=min_unit_bom_purchase_price %}</td>
<td>Max: {% include "price.html" with price=max_unit_bom_purchase_price %}</td>
</tr>
{% if quantity > 1 %}
<tr>
<td></td>
<td>{% trans 'Total Purchase Price' %}</td>
<td>Min: {% include "price.html" with price=min_total_bom_purchase_price %}</td>
<td>Max: {% include "price.html" with price=max_total_bom_purchase_price %}</td>
</tr>
{% endif %}
{% endif %}
{% if not part.has_complete_bom_pricing %}
<tr>
<td colspan='4'>
<span class='warning-msg'><em>{% trans 'Note: BOM pricing is incomplete for this part' %}</em></span>
</td>
</tr>
{% endif %}
{% if min_total_bom_price or min_total_bom_purchase_price %}
{% else %}
<tr>
<td colspan='4'>
<span class='warning-msg'><em>{% trans 'No BOM pricing available' %}</em></span>
</td>
</tr>
{% endif %}
{% endif %}
{% if show_internal_price and roles.sales_order.view %} {% if show_internal_price and roles.sales_order.view %}
{% if total_internal_part_price %} <a href='#internal-cost'>
<tr> <span class='fas fa-dollar-sign'></span>
<td><strong>{% trans 'Internal Price' %}</strong></td> </a>
<td>{% trans 'Unit Cost' %}</td>
<td colspan='2'>{% include "price.html" with price=unit_internal_part_price %}</td>
</tr>
<tr>
<td></td>
<td>{% trans 'Total Cost' %}</td>
<td colspan='2'>{% include "price.html" with price=total_internal_part_price %}</td>
</tr>
{% endif %} {% endif %}
{% endif %}
{% if total_part_price %}
<tr>
<td><strong>{% trans 'Sale Price' %}</strong>
<a href="#sale-cost" title='{% trans "Show sale cost" %}'><span class="fas fa-search-dollar"></span></a>
<a href="#sale-price" title='{% trans "Show sale price" %}'><span class="fas fa-chart-bar"></span></a>
</td> </td>
<td>{% trans 'Unit Cost' %}</td> <th>
<td colspan='2'>{% include "price.html" with price=unit_part_price %}</td> {% trans "Internal Pricing" %}
</th>
<td>{% include "price_data.html" with price=pricing.internal_cost_min %}</td>
<td>{% include "price_data.html" with price=pricing.internal_cost_max %}</td>
</tr>
{% if part.purchaseable %}
<tr>
<td>
{% if roles.purchase_order.view %}
<a href='#purchase-price-history'>
<span class='fas fa-chart-line'></span>
</a>
{% endif %}
</td>
<th>
{% trans "Purchase History" %}
</th>
<td>{% include "price_data.html" with price=pricing.purchase_cost_min %}</td>
<td>{% include "price_data.html" with price=pricing.purchase_cost_max %}</td>
</tr> </tr>
<tr> <tr>
<td></td> <td>
<td>{% trans 'Total Cost' %}</td> {% if roles.purchase_order.view %}
<td colspan='2'>{% include "price.html" with price=total_part_price %}</td> <a href='#supplier-prices'>
<span class='fas fa-building'></span>
</a>
{% endif %}
</td>
<th>
{% trans "Supplier Pricing" %}
</th>
<td>{% include "price_data.html" with price=pricing.supplier_price_min %}</td>
<td>{% include "price_data.html" with price=pricing.supplier_price_max %}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if part.assembly %}
<tr>
<td>
{% if part.has_bom %}
<a href='#bom-cost'>
<span class='fas fa-tools'></span>
</a>
{% endif %}
</td>
<th>
{% trans "BOM Pricing" %}
</th>
<td>{% include "price_data.html" with price=pricing.bom_cost_min %}</td>
<td>{% include "price_data.html" with price=pricing.bom_cost_max %}</td>
</tr>
{% endif %}
{% if part.is_template %}
<tr>
<td><a href='#variant-cost'><span class='fas fa-shapes'></span></a></td>
<th>{% trans "Variant Pricing" %}</th>
<td>{% include "price_data.html" with price=pricing.variant_cost_min %}</td>
<td>{% include "price_data.html" with price=pricing.variant_cost_max %}</td>
</tr>
{% endif %}
<tr>
<td></td>
<th>
{% trans "Overall Pricing" %}
</th>
<th>{% include "price_data.html" with price=pricing.overall_min %}</th>
<th>{% include "price_data.html" with price=pricing.overall_max %}</th>
</tr>
</tbody>
</table>
</div>
<div class='col col-md-6'>
{% if part.salable and roles.sales_order.view %}
<table class='table table-striped table-condensed'>
<col width='25'>
<thead>
<tr>
<th></th>
<th>{% trans "Price Category" %}</th>
<th>{% trans "Minimum" %}</th>
<th>{% trans "Maximum" %}</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<a href='#sale-cost'>
<span class='fas fa-dollar-sign'></span>
</a>
</td>
<th>
{% trans "Sale Price" %}
</th>
<td>
{% include "price_data.html" with price=pricing.sale_price_min %}
</td>
<td>
{% include "price_data.html" with price=pricing.sale_price_max %}
</td>
</tr>
<tr>
<td>
<a href='#sale-price-history'>
<span class='fas fa-chart-line'></span>
</a>
</td>
<th>
{% trans "Sale History" %}
</th>
<td>
{% include "price_data.html" with price=pricing.sale_history_min %}
</td>
<td>
{% include "price_data.html" with price=pricing.sale_history_max %}
</td>
</tr>
</tbody>
</table> </table>
{% if min_unit_buy_price or min_unit_bom_price or min_unit_bom_purchase_price %}
{% else %} {% else %}
<div class='alert alert-danger alert-block'> <div class='alert alert-block alert-info'>
{% trans 'No pricing information is available for this part.' %} {% trans "Sale price data is not available for this part" %}
</div> </div>
{% endif %} {% endif %}
</div> </div>
<div class="col col-md-6">
<h4>{% trans "Calculation parameters" %}</h4>
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<input type="submit" value="{% trans 'Calculate' %}" class="btn btn-primary btn-block">
</form>
</div>
</div>
</div>
{% endif %}
{% if part.purchaseable and roles.purchase_order.view %}
<a class="anchor" id="supplier-cost"></a>
<div class='panel-heading'>
<h4>{% trans "Supplier Cost" %}
<a href="#overview" title='{% trans "Jump to overview" %}'><span class="fas fa-level-up-alt"></span></a>
</h4>
</div>
<div class='panel-content'>
<div class="row">
<div class="col col-md-6">
<h4>{% trans "Suppliers" %}</h4>
<table class="table table-striped table-condensed" id='supplier-table' data-toolbar='#button-toolbar'></table>
</div>
<div class="col col-md-6">
<h4>{% trans "Manufacturers" %}</h4>
<table class="table table-striped table-condensed" id='manufacturer-table' data-toolbar='#button-toolbar'></table>
</div>
</div>
</div>
{% if show_price_history %}
<a class="anchor" id="purchase-price"></a>
<div class='panel-heading'>
<h4>{% trans "Purchase Price" %}
<a href="#overview" title='{% trans "Jump to overview" %}'><span class="fas fa-level-up-alt"></span></a>
</h4>
</div>
<div class='panel-content'>
<h4>{% trans 'Stock Pricing' %}
<em class="fas fa-info-circle" title="Shows the purchase prices of stock for this part.&#10;The Supplier Unit Cost is the current purchase price for that supplier part."></em>
</h4>
{% if price_history|length > 0 %}
<div style="max-width: 99%; min-height: 300px">
<canvas id="StockPriceChart"></canvas>
</div> </div>
{% else %} {% else %}
<div class='alert alert-danger alert-block'> <div class='alert alert-warning alert-block'>
{% trans 'No stock pricing history is available for this part.' %} {% trans "Price range data is not available for this part." %}
</div> </div>
{% endif %} {% endif %}
{% endwith %}
</div> </div>
{% endif %}
{% endif %}
{% if show_internal_price and roles.sales_order.view %} {% if show_internal_price and roles.sales_order.view %}
<a class="anchor" id="internal-cost"></a> <a class="anchor" id="internal-cost"></a>
<div class='panel-heading'> <div class='panel-heading'>
<h4>{% trans "Internal Cost" %} <div class='d-flex flex-wrap'>
<h4>{% trans "Internal Pricing" %}
<a href="#overview" title='{% trans "Jump to overview" %}'><span class="fas fa-level-up-alt"></span></a> <a href="#overview" title='{% trans "Jump to overview" %}'><span class="fas fa-level-up-alt"></span></a>
</h4> </h4>
</div> {% include "spacer.html" %}
<div class='btn-group' role='group'>
<div class='panel-content'>
<div class="row full-height">
<div class="col col-md-8">
<div style="max-width: 99%; height: 100%;">
<canvas id="InternalPriceBreakChart"></canvas>
</div>
</div>
<div class="col col-md-4">
<div id='internal-price-break-toolbar' class='btn-group'>
<button class='btn btn-success' id='new-internal-price-break' type='button'> <button class='btn btn-success' id='new-internal-price-break' type='button'>
<span class='fas fa-plus-circle'></span> {% trans "Add Internal Price Break" %} <span class='fas fa-plus-circle'></span> {% trans "Add Internal Price Break" %}
</button> </button>
</div> </div>
</div>
</div>
<table class='table table-striped table-condensed' id='internal-price-break-table' data-toolbar='#internal-price-break-toolbar' <div class='panel-content'>
data-sort-name="quantity" data-sort-order="asc"> <div class="row full-height">
<div class="col col-md-6">
<div style="max-width: 99%; height: 100%;">
<canvas id="InternalPriceBreakChart"></canvas>
</div>
</div>
<div class="col col-md-6">
<table class='table table-striped table-condensed' id='internal-price-break-table'>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% if part.has_bom and roles.sales_order.view %} {% if part.purchaseable and roles.purchase_order.view %}
<a class="anchor" id="bom-cost"></a> <a class="anchor" id="purchase-price-history"></a>
<div class='panel-heading'> <div class='panel-heading'>
<h4>{% trans "BOM Cost" %} <h4>
{% trans "Purchase History" %}
<a href="#overview" title='{% trans "Jump to overview" %}'><span class="fas fa-level-up-alt"></span></a>
</h4>
</div>
<div class='panel-content'>
<div class="row full-height">
<div class="col col-md-6">
<div style="max-width: 99%; height: 100%;">
<canvas id="part-purchase-history-chart"></canvas>
</div>
</div>
<div class="col col-md-6">
<table class='table table-striped table-condensed' id='part-purchase-history-table'>
</table>
</div>
</div>
</div>
<a class="anchor" id="supplier-prices"></a>
<div class='panel-heading'>
<h4>
{% trans "Supplier Pricing" %}
<a href="#overview" title='{% trans "Jump to overview" %}'><span class="fas fa-level-up-alt"></span></a> <a href="#overview" title='{% trans "Jump to overview" %}'><span class="fas fa-level-up-alt"></span></a>
</h4> </h4>
</div> </div>
<div class='panel-content'> <div class='panel-content'>
<div class="row"> <div class='row full-height'>
<div class="col col-md-6"> <div class="col col-md-6">
<table class='table table-bom table-condensed' data-toolbar="#button-toolbar" id='bom-pricing-table'></table> <div style="max-width: 99%; height: 100%;">
<canvas id="part-supplier-pricing-chart"></canvas>
</div>
</div> </div>
{% if part.bom_count > 0 %}
<div class="col col-md-6"> <div class="col col-md-6">
<h4>{% trans 'BOM Pricing' %}</h4> <table class='table table-striped table-condensed' id='part-supplier-pricing-table'>
<div style="max-width: 99%;"> </table>
<canvas id="BomChart"></canvas> </div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% if part.assembly and part.has_bom %}
<a class="anchor" id="bom-cost"></a>
<div class='panel-heading'>
<h4>{% trans "BOM Pricing" %}
<a href="#overview" title='{% trans "Jump to overview" %}'><span class="fas fa-level-up-alt"></span></a>
</h4>
</div>
<div class='panel-content'>
<div class='row full-height'>
<div class="col col-md-6">
<div style="max-width: 99%; height: 100%;">
<canvas id="bom-pricing-chart"></canvas>
</div>
</div>
<div class="col col-md-6">
<table class='table table-striped table-condensed' id='bom-pricing-table'>
</table>
</div>
</div>
</div>
{% endif %}
{% if part.is_template %}
<a class='anchor' id='variant-cost'></a>
<div class='panel-heading'>
<h4>
{% trans "Variant Pricing" %}
<a href="#overview" title='{% trans "Jump to overview" %}'><span class="fas fa-level-up-alt"></span></a>
</h4>
</div>
<div class='panel-content'>
<div class="row full-height">
<div class="col col-md-6">
<div style="max-width: 99%; height: 100%;">
<canvas id="variant-pricing-chart"></canvas>
</div>
</div>
<div class="col col-md-6">
<table class='table table-striped table-condensed' id='variant-pricing-table'>
</table>
</div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
@@ -258,50 +305,52 @@
{% if part.salable and roles.sales_order.view %} {% if part.salable and roles.sales_order.view %}
<a class="anchor" id="sale-cost"></a> <a class="anchor" id="sale-cost"></a>
<div class='panel-heading'> <div class='panel-heading'>
<h4>{% trans "Sale Cost" %} <div class='d-flex flex-wrap'>
<h4>{% trans "Sale Pricing" %}
<a href="#overview" title='{% trans "Jump to overview" %}'><span class="fas fa-level-up-alt"></span></a>
</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
<button class='btn btn-success' id='new-price-break' type='button'>
<span class='fas fa-plus-circle'></span> {% trans "Add Sell Price Break" %}
</button>
</div>
</div>
</div>
<div class='panel-content'>
<div class="row full-height">
<div class="col col-md-6">
<div style="max-width: 99%; height: 100%;">
<canvas id="SalePriceBreakChart"></canvas>
</div>
</div>
<div class="col col-md-6">
<table class='table table-striped table-condensed' id='price-break-table'>
</table>
</div>
</div>
</div>
<a class="anchor" id="sale-price-history"></a>
<div class='panel-heading'>
<div class='d-flex flex-wrap'></div>
<h4>{% trans "Sale History" %}
<a href="#overview" title='{% trans "Jump to overview" %}'><span class="fas fa-level-up-alt"></span></a> <a href="#overview" title='{% trans "Jump to overview" %}'><span class="fas fa-level-up-alt"></span></a>
</h4> </h4>
</div> </div>
<div class='panel-content'> <div class='panel-content'>
<div class="row full-height"> <div class="row full-height">
<div class="col col-md-8"> <div class="col col-md-6">
<div style="max-width: 99%; height: 100%;"> <div style="max-width: 99%; height: 100%;">
<canvas id="SalePriceBreakChart"></canvas> <canvas id="part-sales-history-chart"></canvas>
</div> </div>
</div> </div>
<div class="col col-md-4"> <div class="col col-md-6">
<div id='price-break-toolbar' class='btn-group'> <table class='table table-striped table-condensed' id='part-sales-history-table'>
<button class='btn btn-success' 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'
data-sort-name="quantity" data-sort-order="asc">
</table> </table>
</div> </div>
</div> </div>
</div> </div>
{% if show_price_history %}
<a class="anchor" id="sale-price"></a>
<div class='panel-heading'>
<h4>{% trans "Sale Price" %}
<a href="#overview" title='{% trans "Jump to overview" %}'><span class="fas fa-level-up-alt"></span></a>
</h4>
</div>
<div class='panel-content'>
{% if sale_history|length > 0 %}
<div style="max-width: 99%; min-height: 300px">
<canvas id="SalePriceChart"></canvas>
</div>
{% else %}
<div class='alert alert-danger alert-block'>
{% trans 'No sale pice history available for this part.' %}
</div>
{% endif %}
</div>
{% endif %}
{% endif %} {% endif %}
@@ -0,0 +1,80 @@
{% load inventree_extras %}
{% load i18n %}
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
{% default_currency as currency %}
// Callback for "part pricing" button
$('#part-pricing-refresh').click(function() {
inventreePut(
'{% url "api-part-pricing" part.pk %}',
{
update: true,
},
{
success: function(response) {
location.reload();
}
}
);
});
// Internal Pricebreaks
{% if show_internal_price and roles.sales_order.view %}
initPriceBreakSet($('#internal-price-break-table'), {
part_id: {{part.id}},
pb_human_name: 'internal price break',
pb_url_slug: 'internal-price',
pb_url: '{% url 'api-part-internal-price-list' %}',
pb_new_btn: $('#new-internal-price-break'),
pb_new_url: '{% url 'api-part-internal-price-list' %}',
linkedGraph: $('#InternalPriceBreakChart'),
});
{% endif %}
// Purchase price history
loadPurchasePriceHistoryTable({
part: {{ part.pk }},
});
{% if part.purchaseable and roles.purchase_order.view %}
// Supplier pricing information
loadPartSupplierPricingTable({
part: {{ part.pk }},
});
{% endif %}
{% if part.assembly and part.has_bom %}
// BOM Pricing Data
loadBomPricingChart({
part: {{ part.pk }}
});
{% endif %}
{% if part.is_template %}
// Variant pricing data
loadVariantPricingChart({
part: {{ part.pk }}
});
{% endif %}
{% if part.salable and roles.sales_order.view %}
// Sales pricebreaks
initPriceBreakSet(
$('#price-break-table'),
{
part_id: {{part.id}},
pb_human_name: 'sale price break',
pb_url_slug: 'sale-price',
pb_url: "{% url 'api-part-sale-price-list' %}",
pb_new_btn: $('#new-price-break'),
pb_new_url: '{% url 'api-part-sale-price-list' %}',
linkedGraph: $('#SalePriceBreakChart'),
},
);
loadSalesPriceHistoryTable({
part: {{ part.pk }}
});
{% endif %}
@@ -4,6 +4,7 @@ import logging
import os import os
import sys import sys
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal
from django import template from django import template
from django.conf import settings as djangosettings from django.conf import settings as djangosettings
@@ -13,6 +14,8 @@ from django.utils.html import format_html
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import moneyed.localization
import InvenTree.helpers import InvenTree.helpers
from common.models import ColorTheme, InvenTreeSetting, InvenTreeUserSetting from common.models import ColorTheme, InvenTreeSetting, InvenTreeUserSetting
from common.settings import currency_code_default from common.settings import currency_code_default
@@ -37,6 +40,12 @@ def define(value, *args, **kwargs):
return value return value
@register.simple_tag()
def decimal(x, *args, **kwargs):
"""Simplified rendering of a decimal number."""
return InvenTree.helpers.decimal2string(x)
@register.simple_tag(takes_context=True) @register.simple_tag(takes_context=True)
def render_date(context, date_object): def render_date(context, date_object):
"""Renders a date according to the preference of the provided user. """Renders a date according to the preference of the provided user.
@@ -94,10 +103,34 @@ def render_date(context, date_object):
return date_object return date_object
@register.simple_tag() @register.simple_tag
def decimal(x, *args, **kwargs): def render_currency(money, decimal_places=None, include_symbol=True):
"""Simplified rendering of a decimal number.""" """Render a currency / Money object"""
return InvenTree.helpers.decimal2string(x)
if money is None or money.amount is None:
return '-'
if decimal_places is None:
decimal_places = InvenTreeSetting.get_setting('PRICING_DECIMAL_PLACES', 6)
value = Decimal(str(money.amount)).normalize()
value = str(value)
if '.' in value:
decimals = len(value.split('.')[-1])
decimals = max(decimals, 2)
decimals = min(decimals, decimal_places)
decimal_places = decimals
else:
decimal_places = 2
return moneyed.localization.format_money(
money,
decimal_places=decimal_places,
include_symbol=include_symbol,
)
@register.simple_tag() @register.simple_tag()
+174 -15
View File
@@ -1,6 +1,7 @@
"""Unit tests for the various part API endpoints""" """Unit tests for the various part API endpoints"""
from decimal import Decimal from decimal import Decimal
from enum import IntEnum
from random import randint from random import randint
from django.urls import reverse from django.urls import reverse
@@ -294,6 +295,114 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
self.assertEqual(response.data['description'], 'A part category') self.assertEqual(response.data['description'], 'A part category')
def test_category_delete(self):
"""Test category deletion with different parameters"""
class Target(IntEnum):
move_subcategories_to_parent_move_parts_to_parent = 0,
move_subcategories_to_parent_delete_parts = 1,
delete_subcategories_move_parts_to_parent = 2,
delete_subcategories_delete_parts = 3,
for i in range(4):
delete_child_categories: bool = False
delete_parts: bool = False
if i == Target.move_subcategories_to_parent_delete_parts \
or i == Target.delete_subcategories_delete_parts:
delete_parts = True
if i == Target.delete_subcategories_move_parts_to_parent \
or i == Target.delete_subcategories_delete_parts:
delete_child_categories = True
# Create a parent category
parent_category = PartCategory.objects.create(
name='Parent category',
description='This is the parent category where the child categories and parts are moved to',
parent=None
)
category_count_before = PartCategory.objects.count()
part_count_before = Part.objects.count()
# Create a category to delete
cat_to_delete = PartCategory.objects.create(
name='Category to delete',
description='This is the category to be deleted',
parent=parent_category
)
url = reverse('api-part-category-detail', kwargs={'pk': cat_to_delete.id})
parts = []
# Create parts in the category to be deleted
for jj in range(3):
parts.append(Part.objects.create(
name=f"Part xyz {jj}",
description="Child part of the deleted category",
category=cat_to_delete
))
child_categories = []
child_categories_parts = []
# Create child categories under the category to be deleted
for ii in range(3):
child = PartCategory.objects.create(
name=f"Child parent_cat {ii}",
description="A child category of the deleted category",
parent=cat_to_delete
)
child_categories.append(child)
# Create parts in the child categories
for jj in range(3):
child_categories_parts.append(Part.objects.create(
name=f"Part xyz {jj}",
description="Child part in the child category of the deleted category",
category=child
))
# Delete the created category (sub categories and their parts will be moved under the parent)
params = {}
if delete_parts:
params['delete_parts'] = '1'
if delete_child_categories:
params['delete_child_categories'] = '1'
response = self.delete(
url,
params,
expected_code=204,
)
self.assertEqual(response.status_code, 204)
if delete_parts:
if i == Target.delete_subcategories_delete_parts:
# Check if all parts deleted
self.assertEqual(Part.objects.count(), part_count_before)
elif i == Target.move_subcategories_to_parent_delete_parts:
# Check if all parts deleted
self.assertEqual(Part.objects.count(), part_count_before + len(child_categories_parts))
else:
# parts moved to the parent category
for part in parts:
part.refresh_from_db()
self.assertEqual(part.category, parent_category)
if delete_child_categories:
for part in child_categories_parts:
part.refresh_from_db()
self.assertEqual(part.category, parent_category)
if delete_child_categories:
# Check if all categories are deleted
self.assertEqual(PartCategory.objects.count(), category_count_before)
else:
# Check if all subcategories to parent moved to parent and all parts deleted
for child in child_categories:
child.refresh_from_db()
self.assertEqual(child.parent, parent_category)
class PartOptionsAPITest(InvenTreeAPITestCase): class PartOptionsAPITest(InvenTreeAPITestCase):
"""Tests for the various OPTIONS endpoints in the /part/ API. """Tests for the various OPTIONS endpoints in the /part/ API.
@@ -1073,17 +1182,17 @@ class PartAPITest(InvenTreeAPITestCase):
url = reverse('api-part-list') url = reverse('api-part-list')
required_cols = [ required_cols = [
'id', 'Part ID',
'name', 'Part Name',
'description', 'Part Description',
'in_stock', 'In Stock',
'category_name', 'Category Name',
'keywords', 'Keywords',
'is_template', 'Template',
'virtual', 'Virtual',
'trackable', 'Trackable',
'active', 'Active',
'notes', 'Notes',
'creation_date', 'creation_date',
] ]
@@ -1108,16 +1217,16 @@ class PartAPITest(InvenTreeAPITestCase):
) )
for row in data: for row in data:
part = Part.objects.get(pk=row['id']) part = Part.objects.get(pk=row['Part ID'])
if part.IPN: if part.IPN:
self.assertEqual(part.IPN, row['IPN']) self.assertEqual(part.IPN, row['IPN'])
self.assertEqual(part.name, row['name']) self.assertEqual(part.name, row['Part Name'])
self.assertEqual(part.description, row['description']) self.assertEqual(part.description, row['Part Description'])
if part.category: if part.category:
self.assertEqual(part.category.name, row['category_name']) self.assertEqual(part.category.name, row['Category Name'])
class PartDetailTests(InvenTreeAPITestCase): class PartDetailTests(InvenTreeAPITestCase):
@@ -1452,6 +1561,56 @@ class PartDetailTests(InvenTreeAPITestCase):
self.assertIn('Ensure this field has no more than 50000 characters', str(response.data['notes'])) self.assertIn('Ensure this field has no more than 50000 characters', str(response.data['notes']))
class PartPricingDetailTests(InvenTreeAPITestCase):
"""Tests for the part pricing API endpoint"""
fixtures = [
'category',
'part',
'location',
]
roles = [
'part.change',
]
def url(self, pk):
"""Construct a pricing URL"""
return reverse('api-part-pricing', kwargs={'pk': pk})
def test_pricing_detail(self):
"""Test an empty pricing detail"""
response = self.get(
self.url(1),
expected_code=200
)
# Check for expected fields
expected_fields = [
'currency',
'updated',
'bom_cost_min',
'bom_cost_max',
'purchase_cost_min',
'purchase_cost_max',
'internal_cost_min',
'internal_cost_max',
'supplier_price_min',
'supplier_price_max',
'overall_min',
'overall_max',
]
for field in expected_fields:
self.assertIn(field, response.data)
# Empty fields (no pricing by default)
for field in expected_fields[2:]:
self.assertIsNone(response.data[field])
class PartAPIAggregationTest(InvenTreeAPITestCase): class PartAPIAggregationTest(InvenTreeAPITestCase):
"""Tests to ensure that the various aggregation annotations are working correctly...""" """Tests to ensure that the various aggregation annotations are working correctly..."""
+21 -22
View File
@@ -58,21 +58,20 @@ class BomExportTest(InvenTreeTestCase):
break break
expected = [ expected = [
'part_id', 'Part ID',
'part_ipn', 'Part IPN',
'part_name', 'Quantity',
'quantity', 'Reference',
'Note',
'optional', 'optional',
'overage', 'overage',
'reference',
'note',
'inherited', 'inherited',
'allow_variants', 'allow_variants',
] ]
# Ensure all the expected headers are in the provided file # Ensure all the expected headers are in the provided file
for header in expected: for header in expected:
self.assertTrue(header in headers) self.assertIn(header, headers)
def test_export_csv(self): def test_export_csv(self):
"""Test BOM download in CSV format.""" """Test BOM download in CSV format."""
@@ -106,22 +105,22 @@ class BomExportTest(InvenTreeTestCase):
break break
expected = [ expected = [
'level', 'BOM Level',
'bom_id', 'BOM Item ID',
'parent_part_id', 'Parent ID',
'parent_part_ipn', 'Parent IPN',
'parent_part_name', 'Parent Name',
'part_id', 'Part ID',
'part_ipn', 'Part IPN',
'part_name', 'Part Name',
'part_description', 'Description',
'sub_assembly', 'Assembly',
'quantity', 'Quantity',
'optional', 'optional',
'consumable', 'consumable',
'overage', 'overage',
'reference', 'Reference',
'note', 'Note',
'inherited', 'inherited',
'allow_variants', 'allow_variants',
'Default Location', 'Default Location',
@@ -131,10 +130,10 @@ class BomExportTest(InvenTreeTestCase):
] ]
for header in expected: for header in expected:
self.assertTrue(header in headers) self.assertIn(header, headers)
for header in headers: for header in headers:
self.assertTrue(header in expected) self.assertIn(header, expected)
def test_export_xls(self): def test_export_xls(self):
"""Test BOM download in XLS format.""" """Test BOM download in XLS format."""
+1 -1
View File
@@ -148,7 +148,7 @@ class CategoryTest(TestCase):
def test_parameters(self): def test_parameters(self):
"""Test that the Category parameters are correctly fetched.""" """Test that the Category parameters are correctly fetched."""
# Check number of SQL queries to iterate other parameters # Check number of SQL queries to iterate other parameters
with self.assertNumQueries(7): with self.assertNumQueries(8):
# Prefetch: 3 queries (parts, parameters and parameters_template) # Prefetch: 3 queries (parts, parameters and parameters_template)
fasteners = self.fasteners.prefetch_parts_parameters() fasteners = self.fasteners.prefetch_parts_parameters()
# Iterate through all parts and parameters # Iterate through all parts and parameters
+330
View File
@@ -0,0 +1,330 @@
"""Unit tests for Part pricing calculations"""
from django.core.exceptions import ObjectDoesNotExist
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from djmoney.money import Money
import common.models
import common.settings
import company.models
import order.models
import part.models
from InvenTree.helpers import InvenTreeTestCase
from InvenTree.status_codes import PurchaseOrderStatus
class PartPricingTests(InvenTreeTestCase):
"""Unit tests for part pricing calculations"""
def generate_exchange_rates(self):
"""Generate some exchange rates to work with"""
rates = {
'AUD': 1.5,
'CAD': 1.7,
'GBP': 0.9,
'USD': 1.0,
}
# Create a dummy backend
ExchangeBackend.objects.create(
name='InvenTreeExchange',
base_currency='USD',
)
backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
for currency, rate in rates.items():
Rate.objects.create(
currency=currency,
value=rate,
backend=backend,
)
def setUp(self):
"""Setup routines"""
self.generate_exchange_rates()
# Create a new part for performing pricing calculations
self.part = part.models.Part.objects.create(
name='PP',
description='A part with pricing',
assembly=True
)
return super().setUp()
def create_price_breaks(self):
"""Create some price breaks for the part, in various currencies"""
# First supplier part (CAD)
self.supplier_1 = company.models.Company.objects.create(
name='Supplier 1',
is_supplier=True
)
self.sp_1 = company.models.SupplierPart.objects.create(
supplier=self.supplier_1,
part=self.part,
SKU='SUP_1',
)
company.models.SupplierPriceBreak.objects.create(
part=self.sp_1,
quantity=1,
price=10.4,
price_currency='CAD',
)
# Second supplier part (AUD)
self.supplier_2 = company.models.Company.objects.create(
name='Supplier 2',
is_supplier=True
)
self.sp_2 = company.models.SupplierPart.objects.create(
supplier=self.supplier_2,
part=self.part,
SKU='SUP_2',
pack_size=2.5,
)
self.sp_3 = company.models.SupplierPart.objects.create(
supplier=self.supplier_2,
part=self.part,
SKU='SUP_3',
pack_size=10
)
company.models.SupplierPriceBreak.objects.create(
part=self.sp_2,
quantity=5,
price=7.555,
price_currency='AUD',
)
# Third supplier part (GBP)
company.models.SupplierPriceBreak.objects.create(
part=self.sp_2,
quantity=10,
price=4.55,
price_currency='GBP',
)
def test_pricing_data(self):
"""Test link between Part and PartPricing model"""
# Initially there is no associated Pricing data
with self.assertRaises(ObjectDoesNotExist):
pricing = self.part.pricing_data
# Accessing in this manner should create the associated PartPricing instance
pricing = self.part.pricing
self.assertEqual(pricing.part, self.part)
# Default values should be null
self.assertIsNone(pricing.bom_cost_min)
self.assertIsNone(pricing.bom_cost_max)
self.assertIsNone(pricing.internal_cost_min)
self.assertIsNone(pricing.internal_cost_max)
self.assertIsNone(pricing.overall_min)
self.assertIsNone(pricing.overall_max)
def test_invalid_rate(self):
"""Ensure that conversion behaves properly with missing rates"""
...
def test_simple(self):
"""Tests for hard-coded values"""
pricing = self.part.pricing
# Add internal pricing
pricing.internal_cost_min = Money(1, 'USD')
pricing.internal_cost_max = Money(4, 'USD')
pricing.save()
self.assertEqual(pricing.overall_min, Money('1', 'USD'))
self.assertEqual(pricing.overall_max, Money('4', 'USD'))
# Add supplier pricing
pricing.supplier_price_min = Money(10, 'AUD')
pricing.supplier_price_max = Money(15, 'CAD')
pricing.save()
# Minimum pricing should not have changed
self.assertEqual(pricing.overall_min, Money('1', 'USD'))
# Maximum price has changed, and was specified in a different currency
self.assertEqual(pricing.overall_max, Money('8.823529', 'USD'))
# Add BOM cost
pricing.bom_cost_min = Money(0.1, 'GBP')
pricing.bom_cost_max = Money(25, 'USD')
pricing.save()
self.assertEqual(pricing.overall_min, Money('0.111111', 'USD'))
self.assertEqual(pricing.overall_max, Money('25', 'USD'))
def test_supplier_part_pricing(self):
"""Test for supplier part pricing"""
pricing = self.part.pricing
# Initially, no information (not yet calculated)
self.assertIsNone(pricing.supplier_price_min)
self.assertIsNone(pricing.supplier_price_max)
self.assertIsNone(pricing.overall_min)
self.assertIsNone(pricing.overall_max)
# Creating price breaks will cause the pricing to be updated
self.create_price_breaks()
pricing.update_pricing()
self.assertEqual(pricing.overall_min, Money('2.014667', 'USD'))
self.assertEqual(pricing.overall_max, Money('6.117647', 'USD'))
# Delete all supplier parts and re-calculate
self.part.supplier_parts.all().delete()
pricing.update_pricing()
pricing.refresh_from_db()
self.assertIsNone(pricing.supplier_price_min)
self.assertIsNone(pricing.supplier_price_max)
def test_internal_pricing(self):
"""Tests for internal price breaks"""
# Ensure internal pricing is enabled
common.models.InvenTreeSetting.set_setting('PART_INTERNAL_PRICE', True, None)
pricing = self.part.pricing
# Initially, no internal price breaks
self.assertIsNone(pricing.internal_cost_min)
self.assertIsNone(pricing.internal_cost_max)
currency = common.settings.currency_code_default()
for ii in range(5):
# Let's add some internal price breaks
part.models.PartInternalPriceBreak.objects.create(
part=self.part,
quantity=ii + 1,
price=10 - ii,
price_currency=currency
)
pricing.update_internal_cost()
# Expected money value
m_expected = Money(10 - ii, currency)
# Minimum cost should keep decreasing as we add more items
self.assertEqual(pricing.internal_cost_min, m_expected)
self.assertEqual(pricing.overall_min, m_expected)
# Maximum cost should stay the same
self.assertEqual(pricing.internal_cost_max, Money(10, currency))
self.assertEqual(pricing.overall_max, Money(10, currency))
def test_bom_pricing(self):
"""Unit test for BOM pricing calculations"""
pricing = self.part.pricing
self.assertIsNone(pricing.bom_cost_min)
self.assertIsNone(pricing.bom_cost_max)
currency = 'AUD'
for ii in range(10):
# Create a new part for the BOM
sub_part = part.models.Part.objects.create(
name=f"Sub Part {ii}",
description="A sub part for use in a BOM",
component=True,
assembly=False,
)
# Create some overall pricing
sub_part_pricing = sub_part.pricing
# Manually override internal price
sub_part_pricing.internal_cost_min = Money(2 * (ii + 1), currency)
sub_part_pricing.internal_cost_max = Money(3 * (ii + 1), currency)
sub_part_pricing.save()
part.models.BomItem.objects.create(
part=self.part,
sub_part=sub_part,
quantity=5,
)
pricing.update_bom_cost()
# Check that the values have been updated correctly
self.assertEqual(pricing.currency, 'USD')
# Final overall pricing checks
self.assertEqual(pricing.overall_min, Money('366.666665', 'USD'))
self.assertEqual(pricing.overall_max, Money('550', 'USD'))
def test_purchase_pricing(self):
"""Unit tests for historical purchase pricing"""
self.create_price_breaks()
pricing = self.part.pricing
# Pre-calculation, pricing should be null
self.assertIsNone(pricing.purchase_cost_min)
self.assertIsNone(pricing.purchase_cost_max)
# Generate some purchase orders
po = order.models.PurchaseOrder.objects.create(
supplier=self.supplier_2,
reference='PO-009',
)
# Add some line items to the order
# $5 AUD each
line_1 = po.add_line_item(self.sp_2, quantity=10, purchase_price=Money(5, 'AUD'))
# $30 CAD each (but pack_size is 10, so really $3 CAD each)
line_2 = po.add_line_item(self.sp_3, quantity=5, purchase_price=Money(30, 'CAD'))
pricing.update_purchase_cost()
# Cost is still null, as the order is not complete
self.assertIsNone(pricing.purchase_cost_min)
self.assertIsNone(pricing.purchase_cost_max)
po.status = PurchaseOrderStatus.COMPLETE
po.save()
pricing.update_purchase_cost()
# Cost is still null, as the lines have not been received
self.assertIsNone(pricing.purchase_cost_min)
self.assertIsNone(pricing.purchase_cost_max)
# Mark items as received
line_1.received = 4
line_1.save()
line_2.received = 5
line_2.save()
pricing.update_purchase_cost()
self.assertEqual(pricing.purchase_cost_min, Money('1.333333', 'USD'))
self.assertEqual(pricing.purchase_cost_max, Money('1.764706', 'USD'))
+3 -123
View File
@@ -11,10 +11,6 @@ from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from djmoney.contrib.exchange.exceptions import MissingRate
from djmoney.contrib.exchange.models import convert_money
import common.settings as inventree_settings
from common.files import FileManager from common.files import FileManager
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
from common.views import FileManagementAjaxView, FileManagementFormView from common.views import FileManagementAjaxView, FileManagementFormView
@@ -22,7 +18,6 @@ from company.models import SupplierPart
from InvenTree.helpers import str2bool from InvenTree.helpers import str2bool
from InvenTree.views import (AjaxUpdateView, AjaxView, InvenTreeRoleMixin, from InvenTree.views import (AjaxUpdateView, AjaxView, InvenTreeRoleMixin,
QRCodeView) QRCodeView)
from order.models import PurchaseOrderLineItem
from plugin.views import InvenTreePluginViewMixin from plugin.views import InvenTreePluginViewMixin
from stock.models import StockItem, StockLocation from stock.models import StockItem, StockLocation
@@ -292,17 +287,6 @@ class PartDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
context.update(**ctx) context.update(**ctx)
show_price_history = InvenTreeSetting.get_setting('PART_SHOW_PRICE_HISTORY', False)
context['show_price_history'] = show_price_history
# Pricing information
if show_price_history:
ctx = self.get_pricing(self.get_quantity())
ctx['form'] = self.form_class(initial=self.get_initials())
context.update(ctx)
return context return context
def get_quantity(self): def get_quantity(self):
@@ -313,113 +297,6 @@ class PartDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
"""Return the Part instance associated with this view""" """Return the Part instance associated with this view"""
return self.get_object() return self.get_object()
def get_pricing(self, quantity=1, currency=None):
"""Returns context with pricing information."""
ctx = PartPricing.get_pricing(self, quantity, currency)
part = self.get_part()
default_currency = inventree_settings.currency_code_default()
# Stock history
if part.total_stock > 1:
price_history = []
stock = part.stock_entries(include_variants=False, in_stock=True).\
order_by('purchase_order__issue_date').prefetch_related('purchase_order', 'supplier_part')
for stock_item in stock:
if None in [stock_item.purchase_price, stock_item.quantity]:
continue
# convert purchase price to current currency - only one currency in the graph
try:
price = convert_money(stock_item.purchase_price, default_currency)
except MissingRate:
continue
line = {
'price': price.amount,
'qty': stock_item.quantity
}
# Supplier Part Name # TODO use in graph
if stock_item.supplier_part:
line['name'] = stock_item.supplier_part.pretty_name
if stock_item.supplier_part.unit_pricing and price:
line['price_diff'] = price.amount - stock_item.supplier_part.unit_pricing
line['price_part'] = stock_item.supplier_part.unit_pricing
# set date for graph labels
if stock_item.purchase_order and stock_item.purchase_order.issue_date:
line['date'] = stock_item.purchase_order.issue_date.isoformat()
elif stock_item.tracking_info.count() > 0:
line['date'] = stock_item.tracking_info.first().date.date().isoformat()
else:
# Not enough information
continue
price_history.append(line)
ctx['price_history'] = price_history
# BOM Information for Pie-Chart
if part.has_bom:
# get internal price setting
use_internal = InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False)
ctx_bom_parts = []
# iterate over all bom-items
for item in part.bom_items.all():
ctx_item = {'name': str(item.sub_part)}
price, qty = item.sub_part.get_price_range(quantity, internal=use_internal), item.quantity
price_min, price_max = 0, 0
if price: # check if price available
price_min = str((price[0] * qty) / quantity)
if len(set(price)) == 2: # min and max-price present
price_max = str((price[1] * qty) / quantity)
ctx['bom_pie_max'] = True # enable showing max prices in bom
ctx_item['max_price'] = price_min
ctx_item['min_price'] = price_max if price_max else price_min
ctx_bom_parts.append(ctx_item)
# add to global context
ctx['bom_parts'] = ctx_bom_parts
# Sale price history
sale_items = PurchaseOrderLineItem.objects.filter(part__part=part).order_by('order__issue_date').\
prefetch_related('order', ).all()
if sale_items:
sale_history = []
for sale_item in sale_items:
# check for not fully defined elements
if None in [sale_item.purchase_price, sale_item.quantity]:
continue
try:
price = convert_money(sale_item.purchase_price, default_currency)
except MissingRate:
continue
line = {
'price': price.amount if price else 0,
'qty': sale_item.quantity,
}
# set date for graph labels
if sale_item.order.issue_date:
line['date'] = sale_item.order.issue_date.isoformat()
elif sale_item.order.creation_date:
line['date'] = sale_item.order.creation_date.isoformat()
else:
line['date'] = _('None')
sale_history.append(line)
ctx['sale_history'] = sale_history
return ctx
def get_initials(self): def get_initials(self):
"""Returns initials for form.""" """Returns initials for form."""
return {'quantity': self.get_quantity()} return {'quantity': self.get_quantity()}
@@ -573,6 +450,8 @@ class BomDownload(AjaxView):
manufacturer_data = str2bool(request.GET.get('manufacturer_data', False)) manufacturer_data = str2bool(request.GET.get('manufacturer_data', False))
pricing_data = str2bool(request.GET.get('pricing_data', False))
levels = request.GET.get('levels', None) levels = request.GET.get('levels', None)
if levels is not None: if levels is not None:
@@ -596,6 +475,7 @@ class BomDownload(AjaxView):
stock_data=stock_data, stock_data=stock_data,
supplier_data=supplier_data, supplier_data=supplier_data,
manufacturer_data=manufacturer_data, manufacturer_data=manufacturer_data,
pricing_data=pricing_data,
) )
def get_data(self): def get_data(self):
+1 -1
View File
@@ -13,7 +13,7 @@ class ActionMixinTests(TestCase):
ACTION_RETURN = 'a action was performed' ACTION_RETURN = 'a action was performed'
def setUp(self): def setUp(self):
"""Setup enviroment for tests. """Setup environment for tests.
Contains multiple sample plugins that are used in the tests Contains multiple sample plugins that are used in the tests
""" """
+2
View File
@@ -121,8 +121,10 @@ def allow_table_event(table_name):
ignore_tables = [ ignore_tables = [
'common_notificationentry', 'common_notificationentry',
'common_notificationmessage',
'common_webhookendpoint', 'common_webhookendpoint',
'common_webhookmessage', 'common_webhookmessage',
'part_partpricing',
] ]
if table_name in ignore_tables: if table_name in ignore_tables:
+1 -1
View File
@@ -311,7 +311,7 @@ class PluginsRegistry:
return collected_plugins return collected_plugins
def install_plugin_file(self): def install_plugin_file(self):
"""Make sure all plugins are installed in the current enviroment.""" """Make sure all plugins are installed in the current environment."""
if settings.PLUGIN_FILE_CHECKED: if settings.PLUGIN_FILE_CHECKED:
logger.info('Plugin file was already checked') logger.info('Plugin file was already checked')
return True return True
+1 -1
View File
@@ -198,7 +198,7 @@ class RegistryTests(TestCase):
def run_package_test(self, directory): def run_package_test(self, directory):
"""General runner for testing package based installs.""" """General runner for testing package based installs."""
# Patch enviroment varible to add dir # Patch environment varible to add dir
envs = {'INVENTREE_PLUGIN_TEST_DIR': directory} envs = {'INVENTREE_PLUGIN_TEST_DIR': directory}
with mock.patch.dict(os.environ, envs): with mock.patch.dict(os.environ, envs):
# Reload to redicsover plugins # Reload to redicsover plugins
+14
View File
@@ -18,6 +18,20 @@ register = template.Library()
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
@register.simple_tag()
def getkey(value: dict, arg):
"""Perform key lookup in the provided dict object.
This function is provided to get around template rendering limitations.
Ref: https://stackoverflow.com/questions/1906129/dict-keys-with-spaces-in-django-templates
Arguments:
value: A python dict object
arg: The 'key' to be found within the dict
"""
return value[arg]
@register.simple_tag() @register.simple_tag()
def asset(filename): def asset(filename):
"""Return fully-qualified path for an upload report asset file. """Return fully-qualified path for an upload report asset file.
+13
View File
@@ -29,6 +29,19 @@ class ReportTagTest(TestCase):
"""Enable or disable debug mode for reports""" """Enable or disable debug mode for reports"""
InvenTreeSetting.set_setting('REPORT_DEBUG_MODE', value, change_user=None) InvenTreeSetting.set_setting('REPORT_DEBUG_MODE', value, change_user=None)
def test_getkey(self):
"""Tests for the 'getkey' template tag"""
data = {
'hello': 'world',
'foo': 'bar',
'with spaces': 'withoutspaces',
1: 2,
}
for k, v in data.items():
self.assertEqual(report_tags.getkey(data, k), v)
def test_asset(self): def test_asset(self):
"""Tests for asset files""" """Tests for asset files"""
+37 -33
View File
@@ -1,6 +1,7 @@
"""Admin for stock app.""" """Admin for stock app."""
from django.contrib import admin from django.contrib import admin
from django.utils.translation import gettext_lazy as _
import import_export.widgets as widgets import import_export.widgets as widgets
from import_export.admin import ImportExportModelAdmin from import_export.admin import ImportExportModelAdmin
@@ -19,9 +20,15 @@ from .models import (StockItem, StockItemAttachment, StockItemTestResult,
class LocationResource(InvenTreeResource): class LocationResource(InvenTreeResource):
"""Class for managing StockLocation data import/export.""" """Class for managing StockLocation data import/export."""
parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(StockLocation)) id = Field(attribute='pk', column_name=_('Location ID'))
name = Field(attribute='name', column_name=_('Location Name'))
description = Field(attribute='description', column_name=_('Description'))
parent = Field(attribute='parent', column_name=_('Parent ID'), widget=widgets.ForeignKeyWidget(StockLocation))
parent_name = Field(attribute='parent__name', column_name=_('Parent Name'), readonly=True)
pathstring = Field(attribute='pathstring', column_name=_('Location Path'))
parent_name = Field(attribute='parent__name', readonly=True) # Calculated fields
items = Field(attribute='item_count', column_name=_('Stock Items'), widget=widgets.IntegerWidget())
class Meta: class Meta:
"""Metaclass options.""" """Metaclass options."""
@@ -35,6 +42,8 @@ class LocationResource(InvenTreeResource):
# Exclude MPTT internal model fields # Exclude MPTT internal model fields
'lft', 'rght', 'tree_id', 'level', 'lft', 'rght', 'tree_id', 'level',
'metadata', 'metadata',
'barcode_data', 'barcode_hash',
'owner', 'icon',
] ]
def after_import(self, dataset, result, using_transactions, dry_run, **kwargs): def after_import(self, dataset, result, using_transactions, dry_run, **kwargs):
@@ -71,39 +80,32 @@ class LocationAdmin(ImportExportModelAdmin):
class StockItemResource(InvenTreeResource): class StockItemResource(InvenTreeResource):
"""Class for managing StockItem data import/export.""" """Class for managing StockItem data import/export."""
# Custom managers for ForeignKey fields id = Field(attribute='pk', column_name=_('Stock Item ID'))
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part)) part = Field(attribute='part', column_name=_('Part ID'), widget=widgets.ForeignKeyWidget(Part))
part_name = Field(attribute='part__full_name', column_name=_('Part Name'), readonly=True)
part_name = Field(attribute='part__full_name', readonly=True) quantity = Field(attribute='quantity', column_name=_('Quantity'))
serial = Field(attribute='serial', column_name=_('Serial'))
supplier_part = Field(attribute='supplier_part', widget=widgets.ForeignKeyWidget(SupplierPart)) batch = Field(attribute='batch', column_name=_('Batch'))
status_label = Field(attribute='status_label', column_name=_('Status'), readonly=True)
supplier = Field(attribute='supplier_part__supplier__id', readonly=True) location = Field(attribute='location', column_name=_('Location ID'), widget=widgets.ForeignKeyWidget(StockLocation))
location_name = Field(attribute='location__name', column_name=_('Location Name'), readonly=True)
customer = Field(attribute='customer', widget=widgets.ForeignKeyWidget(Company)) supplier_part = Field(attribute='supplier_part', column_name=_('Supplier Part ID'), widget=widgets.ForeignKeyWidget(SupplierPart))
supplier = Field(attribute='supplier_part__supplier__id', column_name=_('Supplier ID'), readonly=True)
supplier_name = Field(attribute='supplier_part__supplier__name', readonly=True) supplier_name = Field(attribute='supplier_part__supplier__name', column_name=_('Supplier Name'), readonly=True)
customer = Field(attribute='customer', column_name=_('Customer ID'), widget=widgets.ForeignKeyWidget(Company))
status_label = Field(attribute='status_label', readonly=True) belongs_to = Field(attribute='belongs_to', column_name=_('Installed In'), widget=widgets.ForeignKeyWidget(StockItem))
build = Field(attribute='build', column_name=_('Build ID'), widget=widgets.ForeignKeyWidget(Build))
location = Field(attribute='location', widget=widgets.ForeignKeyWidget(StockLocation)) parent = Field(attribute='parent', column_name=_('Parent ID'), widget=widgets.ForeignKeyWidget(StockItem))
sales_order = Field(attribute='sales_order', column_name=_('Sales Order ID'), widget=widgets.ForeignKeyWidget(SalesOrder))
location_name = Field(attribute='location__name', readonly=True) purchase_order = Field(attribute='purchase_order', column_name=_('Purchase Order ID'), widget=widgets.ForeignKeyWidget(PurchaseOrder))
packaging = Field(attribute='packaging', column_name=_('Packaging'))
belongs_to = Field(attribute='belongs_to', widget=widgets.ForeignKeyWidget(StockItem)) link = Field(attribute='link', column_name=_('Link'))
notes = Field(attribute='notes', column_name=_('Notes'))
build = Field(attribute='build', widget=widgets.ForeignKeyWidget(Build))
parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(StockItem))
sales_order = Field(attribute='sales_order', widget=widgets.ForeignKeyWidget(SalesOrder))
purchase_order = Field(attribute='purchase_order', widget=widgets.ForeignKeyWidget(PurchaseOrder))
# Date management # Date management
updated = Field(attribute='updated', widget=widgets.DateWidget()) updated = Field(attribute='updated', column_name=_('Last Updated'), widget=widgets.DateWidget())
stocktake_date = Field(attribute='stocktake_date', column_name=_('Stocktake'), widget=widgets.DateWidget())
stocktake_date = Field(attribute='stocktake_date', widget=widgets.DateWidget()) expiry_date = Field(attribute='expiry_date', column_name=_('Expiry Date'), widget=widgets.DateWidget())
def after_import(self, dataset, result, using_transactions, dry_run, **kwargs): def after_import(self, dataset, result, using_transactions, dry_run, **kwargs):
"""Rebuild after import to keep tree intact.""" """Rebuild after import to keep tree intact."""
@@ -125,6 +127,8 @@ class StockItemResource(InvenTreeResource):
'lft', 'rght', 'tree_id', 'level', 'lft', 'rght', 'tree_id', 'level',
# Exclude internal fields # Exclude internal fields
'serial_int', 'metadata', 'serial_int', 'metadata',
'barcode_hash', 'barcode_data',
'owner',
] ]
+24 -4
View File
@@ -27,14 +27,15 @@ from InvenTree.api import (APIDownloadMixin, AttachmentMixin,
from InvenTree.filters import InvenTreeOrderingFilter from InvenTree.filters import InvenTreeOrderingFilter
from InvenTree.helpers import (DownloadFile, extract_serial_numbers, isNull, from InvenTree.helpers import (DownloadFile, extract_serial_numbers, isNull,
str2bool, str2int) str2bool, str2int)
from InvenTree.mixins import (CreateAPI, ListAPI, ListCreateAPI, RetrieveAPI, from InvenTree.mixins import (CreateAPI, CustomRetrieveUpdateDestroyAPI,
ListAPI, ListCreateAPI, RetrieveAPI,
RetrieveUpdateAPI, RetrieveUpdateDestroyAPI) RetrieveUpdateAPI, RetrieveUpdateDestroyAPI)
from order.models import PurchaseOrder, SalesOrder, SalesOrderAllocation from order.models import PurchaseOrder, SalesOrder, SalesOrderAllocation
from order.serializers import PurchaseOrderSerializer from order.serializers import PurchaseOrderSerializer
from part.models import BomItem, Part, PartCategory from part.models import BomItem, Part, PartCategory
from part.serializers import PartBriefSerializer from part.serializers import PartBriefSerializer
from plugin.serializers import MetadataSerializer from plugin.serializers import MetadataSerializer
from stock.admin import StockItemResource from stock.admin import LocationResource, StockItemResource
from stock.models import (StockItem, StockItemAttachment, StockItemTestResult, from stock.models import (StockItem, StockItemAttachment, StockItemTestResult,
StockItemTracking, StockLocation) StockItemTracking, StockLocation)
@@ -214,7 +215,7 @@ class StockMerge(CreateAPI):
return ctx return ctx
class StockLocationList(ListCreateAPI): class StockLocationList(APIDownloadMixin, ListCreateAPI):
"""API endpoint for list view of StockLocation objects. """API endpoint for list view of StockLocation objects.
- GET: Return list of StockLocation objects - GET: Return list of StockLocation objects
@@ -224,6 +225,15 @@ class StockLocationList(ListCreateAPI):
queryset = StockLocation.objects.all() queryset = StockLocation.objects.all()
serializer_class = StockSerializers.LocationSerializer serializer_class = StockSerializers.LocationSerializer
def download_queryset(self, queryset, export_format):
"""Download the filtered queryset as a data file"""
dataset = LocationResource().export(queryset=queryset)
filedata = dataset.export(export_format)
filename = f"InvenTree_Locations.{export_format}"
return DownloadFile(filedata, filename)
def get_queryset(self, *args, **kwargs): def get_queryset(self, *args, **kwargs):
"""Return annotated queryset for the StockLocationList endpoint""" """Return annotated queryset for the StockLocationList endpoint"""
@@ -1357,7 +1367,7 @@ class LocationMetadata(RetrieveUpdateAPI):
queryset = StockLocation.objects.all() queryset = StockLocation.objects.all()
class LocationDetail(RetrieveUpdateDestroyAPI): class LocationDetail(CustomRetrieveUpdateDestroyAPI):
"""API endpoint for detail view of StockLocation object. """API endpoint for detail view of StockLocation object.
- GET: Return a single StockLocation object - GET: Return a single StockLocation object
@@ -1375,6 +1385,16 @@ class LocationDetail(RetrieveUpdateDestroyAPI):
queryset = StockSerializers.LocationSerializer.annotate_queryset(queryset) queryset = StockSerializers.LocationSerializer.annotate_queryset(queryset)
return queryset return queryset
def destroy(self, request, *args, **kwargs):
"""Delete a Stock location instance via the API"""
delete_stock_items = 'delete_stock_items' in request.data and request.data['delete_stock_items'] == '1'
delete_sub_locations = 'delete_sub_locations' in request.data and request.data['delete_sub_locations'] == '1'
return super().destroy(request,
*args,
**dict(kwargs,
delete_sub_locations=delete_sub_locations,
delete_stock_items=delete_stock_items))
stock_api_urls = [ stock_api_urls = [
re_path(r'^location/', include([ re_path(r'^location/', include([
@@ -0,0 +1,20 @@
# Generated by Django 3.2.16 on 2022-11-11 01:53
import InvenTree.fields
from django.db import migrations
import djmoney.models.validators
class Migration(migrations.Migration):
dependencies = [
('stock', '0088_remove_stockitem_infinite'),
]
operations = [
migrations.AlterField(
model_name='stockitem',
name='purchase_price',
field=InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Single unit purchase price at time of purchase', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Purchase Price'),
),
]
+35 -19
View File
@@ -43,9 +43,36 @@ class StockLocation(InvenTreeBarcodeMixin, MetadataMixin, InvenTreeTree):
"""Organization tree for StockItem objects. """Organization tree for StockItem objects.
A "StockLocation" can be considered a warehouse, or storage location A "StockLocation" can be considered a warehouse, or storage location
Stock locations can be heirarchical as required Stock locations can be hierarchical as required
""" """
def delete_recursive(self, *args, **kwargs):
"""This function handles the recursive deletion of sub-locations depending on kwargs contents"""
delete_stock_items = kwargs.get('delete_stock_items', False)
parent_location = kwargs.get('parent_location', None)
if parent_location is None:
# First iteration, (no parent_location kwargs passed)
parent_location = self.parent
for child_item in self.get_stock_items(False):
if delete_stock_items:
child_item.delete()
else:
child_item.location = parent_location
child_item.save()
for child_location in self.children.all():
if kwargs.get('delete_sub_locations', False):
child_location.delete_recursive(**dict(delete_sub_locations=True,
delete_stock_items=delete_stock_items,
parent_location=parent_location))
else:
child_location.parent = parent_location
child_location.save()
super().delete(*args, **dict())
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
"""Custom model deletion routine, which updates any child locations or items. """Custom model deletion routine, which updates any child locations or items.
@@ -53,24 +80,13 @@ class StockLocation(InvenTreeBarcodeMixin, MetadataMixin, InvenTreeTree):
""" """
with transaction.atomic(): with transaction.atomic():
parent = self.parent self.delete_recursive(**dict(delete_stock_items=kwargs.get('delete_stock_items', False),
tree_id = self.tree_id delete_sub_locations=kwargs.get('delete_sub_locations', False),
parent_category=self.parent))
# Update each stock item in the stock location if self.parent is not None:
for item in self.stock_items.all():
item.location = self.parent
item.save()
# Update each child category
for child in self.children.all():
child.parent = self.parent
child.save()
super().delete(*args, **kwargs)
if parent is not None:
# Partially rebuild the tree (cheaper than a complete rebuild) # Partially rebuild the tree (cheaper than a complete rebuild)
StockLocation.objects.partial_rebuild(tree_id) StockLocation.objects.partial_rebuild(self.tree_id)
else: else:
StockLocation.objects.rebuild() StockLocation.objects.rebuild()
@@ -656,7 +672,7 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
link = InvenTreeURLField( link = InvenTreeURLField(
verbose_name=_('External Link'), verbose_name=_('External Link'),
blank=True, max_length=200, blank=True,
help_text=_("Link to external URL") help_text=_("Link to external URL")
) )
@@ -735,7 +751,7 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
purchase_price = InvenTreeModelMoneyField( purchase_price = InvenTreeModelMoneyField(
max_digits=19, max_digits=19,
decimal_places=4, decimal_places=6,
blank=True, blank=True,
null=True, null=True,
verbose_name=_('Purchase Price'), verbose_name=_('Purchase Price'),
+11 -12
View File
@@ -65,6 +65,10 @@ class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer):
'barcode_hash', 'barcode_hash',
] ]
read_only_fields = [
'barcode_hash',
]
def validate_serial(self, value): def validate_serial(self, value):
"""Make sure serial is not to big.""" """Make sure serial is not to big."""
if abs(extract_int(value)) > 0x7fffffff: if abs(extract_int(value)) > 0x7fffffff:
@@ -167,7 +171,7 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
purchase_price = InvenTree.serializers.InvenTreeMoneySerializer( purchase_price = InvenTree.serializers.InvenTreeMoneySerializer(
label=_('Purchase Price'), label=_('Purchase Price'),
max_digits=19, decimal_places=4, max_digits=19, decimal_places=6,
allow_null=True, allow_null=True,
help_text=_('Purchase price of this stock item'), help_text=_('Purchase price of this stock item'),
) )
@@ -179,16 +183,6 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
help_text=_('Purchase currency of this stock item'), help_text=_('Purchase currency of this stock item'),
) )
purchase_price_string = serializers.SerializerMethodField()
def get_purchase_price_string(self, obj):
"""Return purchase price as string."""
if obj.purchase_price:
obj.purchase_price.decimal_places_display = 4
return str(obj.purchase_price)
return '-'
purchase_order_reference = serializers.CharField(source='purchase_order.reference', read_only=True) purchase_order_reference = serializers.CharField(source='purchase_order.reference', read_only=True)
sales_order_reference = serializers.CharField(source='sales_order.reference', read_only=True) sales_order_reference = serializers.CharField(source='sales_order.reference', read_only=True)
@@ -249,7 +243,6 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
'updated', 'updated',
'purchase_price', 'purchase_price',
'purchase_price_currency', 'purchase_price_currency',
'purchase_price_string',
] ]
""" """
@@ -258,6 +251,7 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
""" """
read_only_fields = [ read_only_fields = [
'allocated', 'allocated',
'barcode_hash',
'stocktake_date', 'stocktake_date',
'stocktake_user', 'stocktake_user',
'updated', 'updated',
@@ -602,6 +596,7 @@ class LocationSerializer(InvenTree.serializers.InvenTreeModelSerializer):
model = StockLocation model = StockLocation
fields = [ fields = [
'pk', 'pk',
'barcode_hash',
'url', 'url',
'name', 'name',
'level', 'level',
@@ -613,6 +608,10 @@ class LocationSerializer(InvenTree.serializers.InvenTreeModelSerializer):
'icon', 'icon',
] ]
read_only_fields = [
'barcode_hash',
]
class StockItemAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSerializer): class StockItemAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSerializer):
"""Serializer for StockItemAttachment model.""" """Serializer for StockItemAttachment model."""
+127 -10
View File
@@ -3,6 +3,7 @@
import io import io
import os import os
from datetime import datetime, timedelta from datetime import datetime, timedelta
from enum import IntEnum
import django.http import django.http
from django.urls import reverse from django.urls import reverse
@@ -15,6 +16,7 @@ import part.models
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
from InvenTree.api_tester import InvenTreeAPITestCase from InvenTree.api_tester import InvenTreeAPITestCase
from InvenTree.status_codes import StockStatus from InvenTree.status_codes import StockStatus
from part.models import Part
from stock.models import StockItem, StockItemTestResult, StockLocation from stock.models import StockItem, StockItemTestResult, StockLocation
@@ -37,6 +39,7 @@ class StockAPITestCase(InvenTreeAPITestCase):
'stock.add', 'stock.add',
'stock_location.change', 'stock_location.change',
'stock_location.add', 'stock_location.add',
'stock_location.delete',
'stock.delete', 'stock.delete',
] ]
@@ -107,6 +110,121 @@ class StockLocationTest(StockAPITestCase):
response = self.client.post(self.list_url, data, format='json') response = self.client.post(self.list_url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.status_code, status.HTTP_201_CREATED)
def test_stock_location_delete(self):
"""Test stock location deletion with different parameters"""
class Target(IntEnum):
move_sub_locations_to_parent_move_stockitems_to_parent = 0,
move_sub_locations_to_parent_delete_stockitems = 1,
delete_sub_locations_move_stockitems_to_parent = 2,
delete_sub_locations_delete_stockitems = 3,
# First, construct a set of template / variant parts
part = Part.objects.create(
name='Part for stock item creation', description='Part for stock item creation',
category=None,
is_template=False,
)
for i in range(4):
delete_sub_locations: bool = False
delete_stock_items: bool = False
if i == Target.move_sub_locations_to_parent_delete_stockitems \
or i == Target.delete_sub_locations_delete_stockitems:
delete_stock_items = True
if i == Target.delete_sub_locations_move_stockitems_to_parent \
or i == Target.delete_sub_locations_delete_stockitems:
delete_sub_locations = True
# Create a parent stock location
parent_stock_location = StockLocation.objects.create(
name='Parent stock location',
description='This is the parent stock location where the sub categories and stock items are moved to',
parent=None
)
stocklocation_count_before = StockLocation.objects.count()
stock_location_count_before = StockItem.objects.count()
# Create a stock location to be deleted
stock_location_to_delete = StockLocation.objects.create(
name='Stock location to delete',
description='This is the stock location to be deleted',
parent=parent_stock_location
)
url = reverse('api-location-detail', kwargs={'pk': stock_location_to_delete.id})
stock_items = []
# Create stock items in the location to be deleted
for jj in range(3):
stock_items.append(StockItem.objects.create(
batch=f"Stock Item xyz {jj}",
location=stock_location_to_delete,
part=part
))
child_stock_locations = []
child_stock_locations_items = []
# Create sub location under the stock location to be deleted
for ii in range(3):
child = StockLocation.objects.create(
name=f"Sub-location {ii}",
description="A sub-location of the deleted stock location",
parent=stock_location_to_delete
)
child_stock_locations.append(child)
# Create stock items in the sub locations
for jj in range(3):
child_stock_locations_items.append(StockItem.objects.create(
batch=f"Stock item in sub location xyz {jj}",
part=part,
location=child
))
# Delete the created stock location
params = {}
if delete_stock_items:
params['delete_stock_items'] = '1'
if delete_sub_locations:
params['delete_sub_locations'] = '1'
response = self.delete(
url,
params,
expected_code=204,
)
self.assertEqual(response.status_code, 204)
if delete_stock_items:
if i == Target.delete_sub_locations_delete_stockitems:
# Check if all sub-categories deleted
self.assertEqual(StockItem.objects.count(), stock_location_count_before)
elif i == Target.move_sub_locations_to_parent_delete_stockitems:
# Check if all stock locations deleted
self.assertEqual(StockItem.objects.count(), stock_location_count_before + len(child_stock_locations_items))
else:
# Stock locations moved to the parent location
for stock_item in stock_items:
stock_item.refresh_from_db()
self.assertEqual(stock_item.location, parent_stock_location)
if delete_sub_locations:
for child_stock_location_item in child_stock_locations_items:
child_stock_location_item.refresh_from_db()
self.assertEqual(child_stock_location_item.location, parent_stock_location)
if delete_sub_locations:
# Check if all sub-locations are deleted
self.assertEqual(StockLocation.objects.count(), stocklocation_count_before)
else:
# Check if all sub-locations moved to the parent
for child in child_stock_locations:
child.refresh_from_db()
self.assertEqual(child.parent, parent_stock_location)
class StockItemListTest(StockAPITestCase): class StockItemListTest(StockAPITestCase):
"""Tests for the StockItem API LIST endpoint.""" """Tests for the StockItem API LIST endpoint."""
@@ -320,12 +438,13 @@ class StockItemListTest(StockAPITestCase):
# Expected headers # Expected headers
headers = [ headers = [
'part', 'Part ID',
'customer', 'Customer ID',
'location', 'Location ID',
'parent', 'Location Name',
'quantity', 'Parent ID',
'status', 'Quantity',
'Status',
] ]
for h in headers: for h in headers:
@@ -567,9 +686,8 @@ class StockItemTest(StockAPITestCase):
data = self.get(url, expected_code=200).data data = self.get(url, expected_code=200).data
# Check fixture values # Check fixture values
self.assertEqual(data['purchase_price'], '123.0000') self.assertEqual(data['purchase_price'], '123.000000')
self.assertEqual(data['purchase_price_currency'], 'AUD') self.assertEqual(data['purchase_price_currency'], 'AUD')
self.assertEqual(data['purchase_price_string'], 'A$123.0000')
# Update just the amount # Update just the amount
data = self.patch( data = self.patch(
@@ -580,7 +698,7 @@ class StockItemTest(StockAPITestCase):
expected_code=200 expected_code=200
).data ).data
self.assertEqual(data['purchase_price'], '456.0000') self.assertEqual(data['purchase_price'], '456.000000')
self.assertEqual(data['purchase_price_currency'], 'AUD') self.assertEqual(data['purchase_price_currency'], 'AUD')
# Update the currency # Update the currency
@@ -604,7 +722,6 @@ class StockItemTest(StockAPITestCase):
).data ).data
self.assertEqual(data['purchase_price'], None) self.assertEqual(data['purchase_price'], None)
self.assertEqual(data['purchase_price_string'], '-')
# Invalid currency code # Invalid currency code
data = self.patch( data = self.patch(
+14 -3
View File
@@ -78,7 +78,7 @@ function addHeaderAction(label, title, icon, options) {
{% settings_value 'HOMEPAGE_PART_STARRED' user=request.user as setting_part_starred %} {% settings_value 'HOMEPAGE_PART_STARRED' user=request.user as setting_part_starred %}
{% settings_value 'HOMEPAGE_CATEGORY_STARRED' user=request.user as setting_category_starred %} {% settings_value 'HOMEPAGE_CATEGORY_STARRED' user=request.user as setting_category_starred %}
{% settings_value 'HOMEPAGE_PART_LATEST' user=request.user as setting_part_latest %} {% settings_value 'HOMEPAGE_PART_LATEST' user=request.user as setting_part_latest %}
{% settings_value 'HOMEPAGE_BOM_VALIDATION' user=request.user as setting_bom_validation %} {% settings_value 'HOMEPAGE_BOM_REQUIRES_VALIDATION' user=request.user as setting_bom_validation %}
{% to_list setting_part_starred setting_part_latest setting_bom_validation as settings_list_part %} {% to_list setting_part_starred setting_part_latest setting_bom_validation as settings_list_part %}
{% if roles.part.view and True in settings_list_part %} {% if roles.part.view and True in settings_list_part %}
@@ -128,8 +128,8 @@ loadSimplePartTable("#table-bom-validation", "{% url 'api-part-list' %}", {
{% settings_value 'HOMEPAGE_STOCK_RECENT' user=request.user as setting_stock_recent %} {% settings_value 'HOMEPAGE_STOCK_RECENT' user=request.user as setting_stock_recent %}
{% settings_value 'HOMEPAGE_STOCK_LOW' user=request.user as setting_stock_low %} {% settings_value 'HOMEPAGE_STOCK_LOW' user=request.user as setting_stock_low %}
{% settings_value 'HOMEPAGE_STOCK_DEPLETED' user=request.user as setting_stock_depleted %} {% settings_value 'HOMEPAGE_SHOW_STOCK_DEPLETED' user=request.user as setting_stock_depleted %}
{% settings_value 'HOMEPAGE_STOCK_NEEDED' user=request.user as setting_stock_needed %} {% settings_value 'HOMEPAGE_BUILD_STOCK_NEEDED' user=request.user as setting_stock_needed %}
{% settings_value "STOCK_ENABLE_EXPIRY" as expiry %} {% settings_value "STOCK_ENABLE_EXPIRY" as expiry %}
{% if expiry %} {% if expiry %}
{% settings_value 'HOMEPAGE_STOCK_EXPIRED' user=request.user as setting_stock_expired %} {% settings_value 'HOMEPAGE_STOCK_EXPIRED' user=request.user as setting_stock_expired %}
@@ -306,6 +306,17 @@ loadSalesOrderTable("#table-so-overdue", {
{% endif %} {% endif %}
{% settings_value 'HOMEPAGE_NEWS' user=request.user as setting_news %}
{% if setting_news and user.is_staff %}
addHeaderTitle('{% trans "InvenTree News" %}');
addHeaderAction('news', '{% trans "Current News" %}', 'fa-newspaper');
loadNewsFeedTable("#table-news", {
url: "{% url 'api-news-list' %}",
});
{% endif %}
enableSidebar( enableSidebar(
'index', 'index',
{ {

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