mirror of
https://github.com/inventree/InvenTree.git
synced 2026-05-23 01:25:45 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
@@ -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
|
||||
@@ -6,6 +6,7 @@ from django.http import JsonResponse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django_q.models import OrmQ
|
||||
from rest_framework import filters, permissions
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import ValidationError
|
||||
@@ -28,6 +29,11 @@ class InfoView(AjaxView):
|
||||
|
||||
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):
|
||||
"""Serve current server information."""
|
||||
data = {
|
||||
@@ -36,6 +42,7 @@ class InfoView(AjaxView):
|
||||
'instance': inventreeInstanceName(),
|
||||
'apiVersion': inventreeApiVersion(),
|
||||
'worker_running': is_worker_running(),
|
||||
'worker_pending_tasks': self.worker_pending_tasks(),
|
||||
'plugins_enabled': settings.PLUGINS_ENABLED,
|
||||
'active_plugins': plugins_info(),
|
||||
}
|
||||
|
||||
@@ -2,11 +2,26 @@
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
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
|
||||
- Add metadata to Company
|
||||
|
||||
|
||||
@@ -68,6 +68,13 @@ class InvenTreeConfig(AppConfig):
|
||||
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...")
|
||||
|
||||
def collect_tasks(self):
|
||||
|
||||
@@ -38,10 +38,8 @@ class InvenTreeURLField(models.URLField):
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialization method for InvenTreeURLField"""
|
||||
|
||||
# Max length for InvenTreeURLField defaults to 200
|
||||
if 'max_length' not in kwargs:
|
||||
kwargs['max_length'] = 200
|
||||
|
||||
# Max length for InvenTreeURLField is set to 200
|
||||
kwargs['max_length'] = 200
|
||||
super().__init__(**kwargs)
|
||||
|
||||
|
||||
@@ -69,6 +67,13 @@ class InvenTreeModelMoneyField(ModelMoneyField):
|
||||
# set defaults
|
||||
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
|
||||
validators = kwargs.get('validators', [])
|
||||
|
||||
@@ -109,6 +114,10 @@ class InvenTreeMoneyField(MoneyField):
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Override initial values with the real info from database."""
|
||||
kwargs.update(money_kwargs())
|
||||
|
||||
kwargs['max_digits'] = 19
|
||||
kwargs['decimal_places'] = 6
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
|
||||
@@ -456,7 +456,7 @@ def WrapWithQuotes(text, quote='"'):
|
||||
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.
|
||||
|
||||
Args:
|
||||
@@ -468,29 +468,16 @@ def MakeBarcode(object_name, object_pk, object_data=None, **kwargs):
|
||||
Returns:
|
||||
json string of the supplied data plus some other data
|
||||
"""
|
||||
|
||||
if object_data is None:
|
||||
object_data = {}
|
||||
|
||||
url = kwargs.get('url', False)
|
||||
brief = kwargs.get('brief', True)
|
||||
|
||||
data = {}
|
||||
|
||||
if url:
|
||||
request = object_data.get('request', None)
|
||||
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
|
||||
if brief:
|
||||
data[cls_name] = object_pk
|
||||
else:
|
||||
data['tool'] = 'InvenTree'
|
||||
data['version'] = InvenTree.version.inventreeVersion()
|
||||
@@ -498,7 +485,7 @@ def MakeBarcode(object_name, object_pk, object_data=None, **kwargs):
|
||||
|
||||
# Ensure PK is included
|
||||
object_data['id'] = object_pk
|
||||
data[object_name] = object_data
|
||||
data[cls_name] = object_data
|
||||
|
||||
return json.dumps(data, sort_keys=True)
|
||||
|
||||
|
||||
@@ -93,11 +93,11 @@ class InvenTreeMetadata(SimpleMetadata):
|
||||
|
||||
# Add a 'DELETE' action if we are allowed to 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
|
||||
if 'GET' in view.allowed_methods and check(user, table, 'view'):
|
||||
actions['GET'] = True
|
||||
actions['GET'] = {}
|
||||
|
||||
metadata['actions'] = actions
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""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 InvenTree.helpers import remove_non_printable_characters, strip_html_tags
|
||||
@@ -106,6 +106,45 @@ class RetrieveUpdateAPI(CleanMixin, generics.RetrieveUpdateAPIView):
|
||||
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):
|
||||
"""View for retrieve, update and destroy API."""
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ from rest_framework.serializers import DecimalField
|
||||
from rest_framework.utils import model_meta
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
from InvenTree.fields import InvenTreeRestURLField
|
||||
from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField
|
||||
from InvenTree.helpers import download_image_from_url
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ class InvenTreeMoneySerializer(MoneyField):
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Overrite default values."""
|
||||
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)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -73,6 +73,7 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
|
||||
serializer_field_mapping = {
|
||||
**serializers.ModelSerializer.serializer_field_mapping,
|
||||
models.URLField: InvenTreeRestURLField,
|
||||
InvenTreeURLField: InvenTreeRestURLField,
|
||||
}
|
||||
|
||||
def __init__(self, instance=None, data=empty, **kwargs):
|
||||
|
||||
@@ -26,10 +26,12 @@ from sentry_sdk.integrations.django import DjangoIntegration
|
||||
from . import config
|
||||
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"
|
||||
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
|
||||
|
||||
# New requirement for django 3.2+
|
||||
@@ -562,12 +564,16 @@ else:
|
||||
# django-q background worker configuration
|
||||
Q_CLUSTER = {
|
||||
'name': 'InvenTree',
|
||||
'label': 'Background Tasks',
|
||||
'workers': int(get_setting('INVENTREE_BACKGROUND_WORKERS', 'background.workers', 4)),
|
||||
'timeout': int(get_setting('INVENTREE_BACKGROUND_TIMEOUT', 'background.timeout', 90)),
|
||||
'retry': 120,
|
||||
'max_attempts': 5,
|
||||
'queue_limit': 50,
|
||||
'catch_up': False,
|
||||
'bulk': 10,
|
||||
'orm': 'default',
|
||||
'cache': 'default',
|
||||
'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
|
||||
for currency in CURRENCIES:
|
||||
if currency not in moneyed.CURRENCIES: # pragma: no cover
|
||||
|
||||
@@ -26,15 +26,14 @@ def is_worker_running(**kwargs):
|
||||
|
||||
"""
|
||||
Sometimes Stat.get_all() returns [].
|
||||
In this case we have the 'heartbeat' task running every 15 minutes.
|
||||
Check to see if we have a result within the last 20 minutes
|
||||
In this case we have the 'heartbeat' task running every 5 minutes.
|
||||
Check to see if we have any successful result within the last 10 minutes
|
||||
"""
|
||||
|
||||
now = timezone.now()
|
||||
past = now - timedelta(minutes=20)
|
||||
past = now - timedelta(minutes=10)
|
||||
|
||||
results = Success.objects.filter(
|
||||
func='InvenTree.tasks.heartbeat',
|
||||
started__gte=past
|
||||
)
|
||||
|
||||
|
||||
@@ -200,7 +200,7 @@ def scheduled_task(interval: str, minutes: int = None, tasklist: TaskRegister =
|
||||
return _task_wrapper
|
||||
|
||||
|
||||
@scheduled_task(ScheduledTask.MINUTES, 15)
|
||||
@scheduled_task(ScheduledTask.MINUTES, 5)
|
||||
def heartbeat():
|
||||
"""Simple task which runs at 5 minute intervals, so we can determine that the background worker is actually running.
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ class ViewTests(InvenTreeTestCase):
|
||||
'server',
|
||||
'login',
|
||||
'barcodes',
|
||||
'currencies',
|
||||
'pricing',
|
||||
'parts',
|
||||
'stock',
|
||||
]
|
||||
|
||||
@@ -275,8 +275,10 @@ class TestHelpers(TestCase):
|
||||
we will simply try multiple times
|
||||
"""
|
||||
|
||||
tries = 0
|
||||
|
||||
with self.assertRaises(expected_error):
|
||||
while retries > 0:
|
||||
while tries < retries:
|
||||
|
||||
try:
|
||||
helpers.download_image_from_url(url, timeout=timeout)
|
||||
@@ -285,9 +287,11 @@ class TestHelpers(TestCase):
|
||||
if type(exc) is expected_error:
|
||||
# Re-throw this error
|
||||
raise exc
|
||||
else:
|
||||
print("Unexpected error:", type(exc), exc)
|
||||
|
||||
time.sleep(30)
|
||||
retries -= 1
|
||||
tries += 1
|
||||
time.sleep(10 * tries)
|
||||
|
||||
# Attempt to download an image which throws a 404
|
||||
dl_helper("https://httpstat.us/404", requests.exceptions.HTTPError, timeout=10)
|
||||
@@ -295,9 +299,6 @@ class TestHelpers(TestCase):
|
||||
# Attempt to download, but timeout
|
||||
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"
|
||||
|
||||
InvenTreeSetting.set_setting('INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE', 1, change_user=None)
|
||||
|
||||
@@ -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'^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'^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'^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'),
|
||||
@@ -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'^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'^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'^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'),
|
||||
|
||||
@@ -55,9 +55,16 @@ class NotificationMessageAdmin(admin.ModelAdmin):
|
||||
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.InvenTreeUserSetting, UserSettingsAdmin)
|
||||
admin.site.register(common.models.WebhookEndpoint, WebhookAdmin)
|
||||
admin.site.register(common.models.WebhookMessage, ImportExportModelAdmin)
|
||||
admin.site.register(common.models.NotificationEntry, NotificationEntryAdmin)
|
||||
admin.site.register(common.models.NotificationMessage, NotificationMessageAdmin)
|
||||
admin.site.register(common.models.NewsFeedEntry, NewsFeedEntryAdmin)
|
||||
|
||||
+51
-65
@@ -11,6 +11,7 @@ from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django_q.tasks import async_task
|
||||
from rest_framework import filters, permissions, serializers
|
||||
from rest_framework.exceptions import NotAcceptable, NotFound
|
||||
from rest_framework.permissions import IsAdminUser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
@@ -18,8 +19,8 @@ import common.models
|
||||
import common.serializers
|
||||
from InvenTree.api import BulkDeleteMixin
|
||||
from InvenTree.helpers import inheritors
|
||||
from InvenTree.mixins import (CreateAPI, ListAPI, RetrieveAPI,
|
||||
RetrieveUpdateAPI, RetrieveUpdateDestroyAPI)
|
||||
from InvenTree.mixins import (ListAPI, RetrieveAPI, RetrieveUpdateAPI,
|
||||
RetrieveUpdateDestroyAPI)
|
||||
from plugin.models import NotificationUserSetting
|
||||
from plugin.serializers import NotificationUserSettingSerializer
|
||||
|
||||
@@ -255,21 +256,20 @@ class NotificationUserSettingsDetail(RetrieveUpdateAPI):
|
||||
|
||||
queryset = NotificationUserSetting.objects.all()
|
||||
serializer_class = NotificationUserSettingSerializer
|
||||
|
||||
permission_classes = [
|
||||
UserSettingsPermissions,
|
||||
]
|
||||
permission_classes = [UserSettingsPermissions, ]
|
||||
|
||||
|
||||
class NotificationList(BulkDeleteMixin, ListAPI):
|
||||
"""List view for all notifications of the current user."""
|
||||
|
||||
class NotificationMessageMixin:
|
||||
"""Generic mixin for NotificationMessage."""
|
||||
queryset = common.models.NotificationMessage.objects.all()
|
||||
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 = [
|
||||
DjangoFilterBackend,
|
||||
@@ -312,65 +312,16 @@ class NotificationList(BulkDeleteMixin, ListAPI):
|
||||
return queryset
|
||||
|
||||
|
||||
class NotificationDetail(RetrieveUpdateDestroyAPI):
|
||||
class NotificationDetail(NotificationMessageMixin, RetrieveUpdateDestroyAPI):
|
||||
"""Detail view for an individual notification object.
|
||||
|
||||
- User can only view / delete their own notification objects
|
||||
"""
|
||||
|
||||
queryset = common.models.NotificationMessage.objects.all()
|
||||
serializer_class = common.serializers.NotificationMessageSerializer
|
||||
permission_classes = [
|
||||
UserSettingsPermissions,
|
||||
]
|
||||
|
||||
|
||||
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):
|
||||
class NotificationReadAll(NotificationMessageMixin, RetrieveAPI):
|
||||
"""API endpoint to mark all notifications as read."""
|
||||
|
||||
queryset = common.models.NotificationMessage.objects.all()
|
||||
|
||||
permission_classes = [
|
||||
UserSettingsPermissions,
|
||||
]
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Set all messages for the current user as read."""
|
||||
try:
|
||||
@@ -380,6 +331,35 @@ class NotificationReadAll(RetrieveAPI):
|
||||
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 = [
|
||||
# User settings
|
||||
re_path(r'^user/', include([
|
||||
@@ -417,8 +397,6 @@ common_api_urls = [
|
||||
re_path(r'^notifications/', include([
|
||||
# Individual purchase order detail URLs
|
||||
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'),
|
||||
])),
|
||||
# Read all
|
||||
@@ -428,4 +406,12 @@ common_api_urls = [
|
||||
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
@@ -1054,37 +1054,6 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'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': {
|
||||
'name': _('Show related parts'),
|
||||
'description': _('Display related parts for a part'),
|
||||
@@ -1099,20 +1068,6 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'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': {
|
||||
'name': _('Part Name Display Format'),
|
||||
'description': _('Format to display the part name'),
|
||||
@@ -1127,6 +1082,49 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'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': {
|
||||
'name': _('Enable label printing'),
|
||||
'description': _('Enable label printing from the web interface'),
|
||||
@@ -1259,6 +1257,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'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': {
|
||||
'name': _('Purchase Order Reference Pattern'),
|
||||
'description': _('Required pattern for generating Purchase Order reference field'),
|
||||
@@ -1266,6 +1271,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'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_ENABLE_PWD_FORGOT': {
|
||||
'name': _('Enable password forgot'),
|
||||
@@ -1448,10 +1460,10 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
|
||||
'validator': [int, MinValueValidator(1)]
|
||||
},
|
||||
|
||||
'HOMEPAGE_BOM_VALIDATION': {
|
||||
'HOMEPAGE_BOM_REQUIRES_VALIDATION': {
|
||||
'name': _('Show unvalidated BOMs'),
|
||||
'description': _('Show BOMs that await validation on the homepage'),
|
||||
'default': True,
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
@@ -1476,17 +1488,17 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'HOMEPAGE_STOCK_DEPLETED': {
|
||||
'HOMEPAGE_SHOW_STOCK_DEPLETED': {
|
||||
'name': _('Show depleted stock'),
|
||||
'description': _('Show depleted stock items on the homepage'),
|
||||
'default': True,
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'HOMEPAGE_STOCK_NEEDED': {
|
||||
'HOMEPAGE_BUILD_STOCK_NEEDED': {
|
||||
'name': _('Show needed stock'),
|
||||
'description': _('Show stock items needed for builds on the homepage'),
|
||||
'default': True,
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
@@ -1546,6 +1558,13 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'HOMEPAGE_NEWS': {
|
||||
'name': _('Show News'),
|
||||
'description': _('Show news on the homepage'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
"LABEL_INLINE": {
|
||||
'name': _('Inline label display'),
|
||||
'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(
|
||||
max_digits=19,
|
||||
decimal_places=4,
|
||||
decimal_places=6,
|
||||
null=True,
|
||||
verbose_name=_('Price'),
|
||||
help_text=_('Unit price at specified quantity'),
|
||||
@@ -2187,14 +2206,13 @@ class NotificationEntry(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:
|
||||
- key: A text entry describing the notification e.g. 'part.notify_low_stock'
|
||||
- uid: An (optional) numerical ID for a particular instance
|
||||
- date: The last time this notification was sent
|
||||
target_object: The 'target' of the notification message
|
||||
source_object: The 'source' of the notification message
|
||||
"""
|
||||
|
||||
# generic link to target
|
||||
@@ -2271,3 +2289,54 @@ class NotificationMessage(models.Model):
|
||||
def age_human(self):
|
||||
"""Humanized age."""
|
||||
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
|
||||
)
|
||||
|
||||
@@ -173,7 +173,7 @@ class MethodStorageClass:
|
||||
user_settings = {}
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.urls import reverse
|
||||
from rest_framework import serializers
|
||||
|
||||
from common.models import (InvenTreeSetting, InvenTreeUserSetting,
|
||||
NotificationMessage)
|
||||
NewsFeedEntry, NotificationMessage)
|
||||
from InvenTree.helpers import construct_absolute_url, get_objectreference
|
||||
from InvenTree.serializers import InvenTreeModelSerializer
|
||||
|
||||
@@ -158,7 +158,7 @@ class NotificationMessageSerializer(InvenTreeModelSerializer):
|
||||
|
||||
age_human = serializers.CharField(read_only=True)
|
||||
|
||||
read = serializers.BooleanField(read_only=True)
|
||||
read = serializers.BooleanField()
|
||||
|
||||
def get_target(self, obj):
|
||||
"""Function to resolve generic object reference to target."""
|
||||
@@ -203,11 +203,22 @@ class NotificationMessageSerializer(InvenTreeModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class NotificationReadSerializer(NotificationMessageSerializer):
|
||||
"""Serializer for reading a notification."""
|
||||
class NewsFeedEntrySerializer(InvenTreeModelSerializer):
|
||||
"""Serializer for the NewsFeedEntry model."""
|
||||
|
||||
def is_valid(self, raise_exception=False):
|
||||
"""Ensure instance data is available for view and let validation pass."""
|
||||
self.instance = self.context['instance'] # set instance that should be returned
|
||||
self._validated_data = True
|
||||
return True
|
||||
read = serializers.BooleanField()
|
||||
|
||||
class Meta:
|
||||
"""Meta options for NewsFeedEntrySerializer."""
|
||||
|
||||
model = NewsFeedEntry
|
||||
fields = [
|
||||
'pk',
|
||||
'feed_id',
|
||||
'title',
|
||||
'link',
|
||||
'published',
|
||||
'author',
|
||||
'summary',
|
||||
'read',
|
||||
]
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import AppRegistryNotReady
|
||||
|
||||
import feedparser
|
||||
|
||||
from InvenTree.tasks import ScheduledTask, scheduled_task
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
@@ -26,3 +29,41 @@ def delete_old_notifications():
|
||||
|
||||
# Delete notification records before the specified date
|
||||
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')
|
||||
|
||||
@@ -57,6 +57,12 @@ class SupplierPartResource(InvenTreeResource):
|
||||
clean_model_instances = True
|
||||
|
||||
|
||||
class SupplierPriceBreakInline(admin.TabularInline):
|
||||
"""Inline for supplier-part pricing"""
|
||||
|
||||
model = SupplierPriceBreak
|
||||
|
||||
|
||||
class SupplierPartAdmin(ImportExportModelAdmin):
|
||||
"""Admin class for the SupplierPart model"""
|
||||
|
||||
@@ -71,6 +77,10 @@ class SupplierPartAdmin(ImportExportModelAdmin):
|
||||
'SKU',
|
||||
]
|
||||
|
||||
inlines = [
|
||||
SupplierPriceBreakInline,
|
||||
]
|
||||
|
||||
autocomplete_fields = ('part', 'supplier', 'manufacturer_part',)
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from django_filters import rest_framework as rest_filters
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework import filters
|
||||
|
||||
import part.models
|
||||
from InvenTree.api import AttachmentMixin, ListCreateDestroyAPIView
|
||||
from InvenTree.filters import InvenTreeOrderingFilter
|
||||
from InvenTree.helpers import str2bool
|
||||
@@ -354,9 +355,6 @@ class SupplierPartList(ListCreateDestroyAPIView):
|
||||
InvenTreeOrderingFilter,
|
||||
]
|
||||
|
||||
filterset_fields = [
|
||||
]
|
||||
|
||||
ordering_fields = [
|
||||
'SKU',
|
||||
'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):
|
||||
"""API endpoint for list view of SupplierPriceBreak object.
|
||||
|
||||
@@ -412,15 +435,35 @@ class SupplierPriceBreakList(ListCreateAPI):
|
||||
|
||||
queryset = SupplierPriceBreak.objects.all()
|
||||
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 = [
|
||||
DjangoFilterBackend,
|
||||
filters.OrderingFilter,
|
||||
]
|
||||
|
||||
filterset_fields = [
|
||||
'part',
|
||||
ordering_fields = [
|
||||
'quantity',
|
||||
]
|
||||
|
||||
ordering = 'quantity'
|
||||
|
||||
|
||||
class SupplierPriceBreakDetail(RetrieveUpdateDestroyAPI):
|
||||
"""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'),
|
||||
),
|
||||
]
|
||||
@@ -8,6 +8,8 @@ from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
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.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -18,6 +20,8 @@ import common.models
|
||||
import common.settings
|
||||
import InvenTree.fields
|
||||
import InvenTree.helpers
|
||||
import InvenTree.ready
|
||||
import InvenTree.tasks
|
||||
import InvenTree.validators
|
||||
from common.settings import currency_code_default
|
||||
from InvenTree.fields import InvenTreeURLField, RoundingDecimalField
|
||||
@@ -101,7 +105,7 @@ class Company(MetadataMixin, models.Model):
|
||||
blank=True,
|
||||
)
|
||||
|
||||
website = models.URLField(
|
||||
website = InvenTreeURLField(
|
||||
blank=True,
|
||||
verbose_name=_('Website'),
|
||||
help_text=_('Company website URL')
|
||||
@@ -691,3 +695,23 @@ class SupplierPriceBreak(common.models.PriceBreak):
|
||||
def __str__(self):
|
||||
"""Format a string representation of a SupplierPriceBreak instance"""
|
||||
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()
|
||||
|
||||
@@ -141,7 +141,7 @@ class ManufacturerPartSerializer(InvenTreeModelSerializer):
|
||||
manufacturer_detail = kwargs.pop('manufacturer_detail', True)
|
||||
prettify = kwargs.pop('pretty', False)
|
||||
|
||||
super(ManufacturerPartSerializer, self).__init__(*args, **kwargs)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if part_detail is not True:
|
||||
self.fields.pop('part_detail')
|
||||
@@ -205,7 +205,7 @@ class ManufacturerPartParameterSerializer(InvenTreeModelSerializer):
|
||||
"""Initialize this serializer with extra detail fields as required"""
|
||||
man_detail = kwargs.pop('manufacturer_part_detail', False)
|
||||
|
||||
super(ManufacturerPartParameterSerializer, self).__init__(*args, **kwargs)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if not man_detail:
|
||||
self.fields.pop('manufacturer_part_detail')
|
||||
@@ -247,13 +247,17 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
||||
# Check if 'available' quantity was supplied
|
||||
self.has_available_quantity = 'available' in kwargs.get('data', {})
|
||||
|
||||
part_detail = kwargs.pop('part_detail', True)
|
||||
supplier_detail = kwargs.pop('supplier_detail', True)
|
||||
manufacturer_detail = kwargs.pop('manufacturer_detail', True)
|
||||
brief = kwargs.pop('brief', False)
|
||||
|
||||
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)
|
||||
|
||||
super(SupplierPartSerializer, self).__init__(*args, **kwargs)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if part_detail is not True:
|
||||
self.fields.pop('part_detail')
|
||||
@@ -263,6 +267,7 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
||||
|
||||
if manufacturer_detail is not True:
|
||||
self.fields.pop('manufacturer_detail')
|
||||
self.fields.pop('manufacturer_part_detail')
|
||||
|
||||
if prettify is not True:
|
||||
self.fields.pop('pretty_name')
|
||||
@@ -294,6 +299,7 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
||||
'MPN',
|
||||
'note',
|
||||
'pk',
|
||||
'barcode_hash',
|
||||
'packaging',
|
||||
'pack_size',
|
||||
'part',
|
||||
@@ -307,6 +313,7 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
||||
|
||||
read_only_fields = [
|
||||
'availability_updated',
|
||||
'barcode_hash',
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
@@ -364,6 +371,20 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
||||
class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
|
||||
"""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()
|
||||
|
||||
price = InvenTreeMoneySerializer(
|
||||
@@ -378,6 +399,13 @@ class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
|
||||
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:
|
||||
"""Metaclass options."""
|
||||
|
||||
@@ -385,8 +413,11 @@ class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
|
||||
fields = [
|
||||
'pk',
|
||||
'part',
|
||||
'part_detail',
|
||||
'quantity',
|
||||
'price',
|
||||
'price_currency',
|
||||
'supplier',
|
||||
'supplier_detail',
|
||||
'updated',
|
||||
]
|
||||
|
||||
@@ -252,6 +252,9 @@ src="{% static 'img/blank_image.png' %}"
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<div id='price-break-toolbar' class='btn-group'>
|
||||
<div class='btn-group' role='group'>
|
||||
{% include "filter_list.html" with id='supplierpricebreak' %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 %}
|
||||
|
||||
function reloadPriceBreaks() {
|
||||
$("#price-break-table").bootstrapTable("refresh");
|
||||
}
|
||||
|
||||
$('#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);
|
||||
}
|
||||
},
|
||||
]
|
||||
loadSupplierPriceBreakTable({
|
||||
part: {{ part.pk }}
|
||||
});
|
||||
|
||||
$('#new-price-break').click(function() {
|
||||
@@ -386,7 +315,9 @@ $('#new-price-break').click(function() {
|
||||
},
|
||||
},
|
||||
title: '{% trans "Add Price Break" %}',
|
||||
onSuccess: reloadPriceBreaks,
|
||||
onSuccess: function() {
|
||||
$("#price-break-table").bootstrapTable("refresh");
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -239,12 +239,6 @@ class ManufacturerTest(InvenTreeAPITestCase):
|
||||
# Check link is not modified
|
||||
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
|
||||
self.assertEqual(response.data['link'], 'https://www.axel-larsson.se/Exego.aspx?p_id=341&ArtNr=0804020E')
|
||||
|
||||
|
||||
@@ -252,7 +252,7 @@ class StockItemLabel(LabelTemplate):
|
||||
'barcode_data': stock_item.barcode_data,
|
||||
'barcode_hash': stock_item.barcode_hash,
|
||||
'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(),
|
||||
'parameters': stock_item.part.parameters_map(),
|
||||
|
||||
@@ -318,6 +318,6 @@ class PartLabel(LabelTemplate):
|
||||
'IPN': part.IPN,
|
||||
'revision': part.revision,
|
||||
'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(),
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"""Tests for labels"""
|
||||
|
||||
import io
|
||||
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.base import ContentFile
|
||||
from django.urls import reverse
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
from InvenTree.helpers import validateFilterString
|
||||
from part.models import Part
|
||||
@@ -73,3 +77,55 @@ class LabelTest(InvenTreeAPITestCase):
|
||||
for label in labels:
|
||||
url = reverse('api-part-label-print', kwargs={'pk': label.pk})
|
||||
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)
|
||||
|
||||
+2309
-1941
File diff suppressed because it is too large
Load Diff
+2309
-1941
File diff suppressed because it is too large
Load Diff
+2363
-1995
File diff suppressed because it is too large
Load Diff
+2309
-1941
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+2309
-1941
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+2309
-1941
File diff suppressed because it is too large
Load Diff
+2338
-1970
File diff suppressed because it is too large
Load Diff
+2309
-1941
File diff suppressed because it is too large
Load Diff
+2366
-1998
File diff suppressed because it is too large
Load Diff
+2309
-1941
File diff suppressed because it is too large
Load Diff
+2349
-1981
File diff suppressed because it is too large
Load Diff
+2309
-1941
File diff suppressed because it is too large
Load Diff
+2310
-1942
File diff suppressed because it is too large
Load Diff
+2338
-1970
File diff suppressed because it is too large
Load Diff
+2335
-1967
File diff suppressed because it is too large
Load Diff
+2357
-1989
File diff suppressed because it is too large
Load Diff
+2373
-2005
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+2349
-1981
File diff suppressed because it is too large
Load Diff
+2309
-1941
File diff suppressed because it is too large
Load Diff
+2309
-1941
File diff suppressed because it is too large
Load Diff
+2308
-1940
File diff suppressed because it is too large
Load Diff
+2309
-1941
File diff suppressed because it is too large
Load Diff
+2340
-1972
File diff suppressed because it is too large
Load Diff
+31
-5
@@ -445,6 +445,19 @@ class PurchaseOrderLineItemFilter(rest_filters.FilterSet):
|
||||
|
||||
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):
|
||||
"""API endpoint for accessing a list of PurchaseOrderLineItem objects.
|
||||
@@ -776,6 +789,22 @@ class SalesOrderLineItemFilter(rest_filters.FilterSet):
|
||||
'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')
|
||||
|
||||
def filter_completed(self, queryset, name, value):
|
||||
@@ -810,6 +839,8 @@ class SalesOrderLineItemList(ListCreateAPI):
|
||||
kwargs['part_detail'] = str2bool(params.get('part_detail', False))
|
||||
kwargs['order_detail'] = str2bool(params.get('order_detail', False))
|
||||
kwargs['allocations'] = str2bool(params.get('allocations', False))
|
||||
kwargs['customer_detail'] = str2bool(params.get('customer_detail', False))
|
||||
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
@@ -853,11 +884,6 @@ class SalesOrderLineItemList(ListCreateAPI):
|
||||
'reference',
|
||||
]
|
||||
|
||||
filterset_fields = [
|
||||
'order',
|
||||
'part',
|
||||
]
|
||||
|
||||
|
||||
class SalesOrderExtraLineList(GeneralExtraLineList, ListCreateAPI):
|
||||
"""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'),
|
||||
),
|
||||
]
|
||||
@@ -24,13 +24,14 @@ from mptt.models import TreeForeignKey
|
||||
|
||||
import InvenTree.helpers
|
||||
import InvenTree.ready
|
||||
import InvenTree.tasks
|
||||
import order.validators
|
||||
from common.notifications import InvenTreeNotificationBodies
|
||||
from common.settings import currency_code_default
|
||||
from company.models import Company, SupplierPart
|
||||
from InvenTree.exceptions import log_error
|
||||
from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeNotesField,
|
||||
RoundingDecimalField)
|
||||
InvenTreeURLField, RoundingDecimalField)
|
||||
from InvenTree.helpers import decimal2string, getSetting, notify_responsible
|
||||
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
|
||||
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'))
|
||||
|
||||
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'))
|
||||
|
||||
@@ -311,6 +312,9 @@ class PurchaseOrder(Order):
|
||||
reference (str, optional): Reference to item. Defaults to ''.
|
||||
purchase_price (optional): Price of item. Defaults to None.
|
||||
|
||||
Returns:
|
||||
The newly created PurchaseOrderLineItem instance
|
||||
|
||||
Raises:
|
||||
ValidationError: quantity is smaller than 0
|
||||
ValidationError: quantity is not type int
|
||||
@@ -338,11 +342,13 @@ class PurchaseOrder(Order):
|
||||
quantity_new = line.quantity + quantity
|
||||
line.quantity = quantity_new
|
||||
supplier_price = supplier_part.get_price(quantity_new)
|
||||
|
||||
if line.purchase_price and supplier_price:
|
||||
line.purchase_price = supplier_price / quantity_new
|
||||
|
||||
line.save()
|
||||
|
||||
return
|
||||
return line
|
||||
|
||||
line = PurchaseOrderLineItem(
|
||||
order=self,
|
||||
@@ -354,6 +360,8 @@ class PurchaseOrder(Order):
|
||||
|
||||
line.save()
|
||||
|
||||
return line
|
||||
|
||||
@transaction.atomic
|
||||
def place_order(self):
|
||||
"""Marks the PurchaseOrder as PLACED.
|
||||
@@ -376,10 +384,21 @@ class PurchaseOrder(Order):
|
||||
if self.status == PurchaseOrderStatus.PLACED:
|
||||
self.status = PurchaseOrderStatus.COMPLETE
|
||||
self.complete_date = datetime.now().date()
|
||||
|
||||
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)
|
||||
|
||||
@property
|
||||
def is_pending(self):
|
||||
"""Return True if the PurchaseOrder is 'pending'"""
|
||||
return self.status == PurchaseOrderStatus.PENDING
|
||||
|
||||
@property
|
||||
def is_overdue(self):
|
||||
"""Returns True if this PurchaseOrder is "overdue".
|
||||
@@ -757,6 +776,10 @@ class SalesOrder(Order):
|
||||
|
||||
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)
|
||||
|
||||
return True
|
||||
@@ -946,7 +969,7 @@ class OrderExtraLine(OrderLineItem):
|
||||
|
||||
price = InvenTreeModelMoneyField(
|
||||
max_digits=19,
|
||||
decimal_places=4,
|
||||
decimal_places=6,
|
||||
null=True, blank=True,
|
||||
allow_negative=True,
|
||||
verbose_name=_('Price'),
|
||||
@@ -1026,7 +1049,7 @@ class PurchaseOrderLineItem(OrderLineItem):
|
||||
|
||||
purchase_price = InvenTreeModelMoneyField(
|
||||
max_digits=19,
|
||||
decimal_places=4,
|
||||
decimal_places=6,
|
||||
null=True, blank=True,
|
||||
verbose_name=_('Purchase Price'),
|
||||
help_text=_('Unit purchase price'),
|
||||
@@ -1132,7 +1155,7 @@ class SalesOrderLineItem(OrderLineItem):
|
||||
|
||||
sale_price = InvenTreeModelMoneyField(
|
||||
max_digits=19,
|
||||
decimal_places=4,
|
||||
decimal_places=6,
|
||||
null=True, blank=True,
|
||||
verbose_name=_('Sale Price'),
|
||||
help_text=_('Unit sale price'),
|
||||
@@ -1254,7 +1277,7 @@ class SalesOrderShipment(models.Model):
|
||||
help_text=_('Reference number for associated invoice'),
|
||||
)
|
||||
|
||||
link = models.URLField(
|
||||
link = InvenTreeURLField(
|
||||
blank=True,
|
||||
verbose_name=_('Link'),
|
||||
help_text=_('Link to external page')
|
||||
|
||||
@@ -39,8 +39,6 @@ class AbstractOrderSerializer(serializers.Serializer):
|
||||
read_only=True,
|
||||
)
|
||||
|
||||
total_price_string = serializers.CharField(source='get_total_price', read_only=True)
|
||||
|
||||
|
||||
class AbstractExtraLineSerializer(serializers.Serializer):
|
||||
"""Abstract Serializer for a ExtraLine object."""
|
||||
@@ -60,8 +58,6 @@ class AbstractExtraLineSerializer(serializers.Serializer):
|
||||
allow_null=True
|
||||
)
|
||||
|
||||
price_string = serializers.CharField(source='price', read_only=True)
|
||||
|
||||
price_currency = serializers.ChoiceField(
|
||||
choices=currency_code_mappings(),
|
||||
help_text=_('Price currency'),
|
||||
@@ -81,7 +77,6 @@ class AbstractExtraLineMeta:
|
||||
'order_detail',
|
||||
'price',
|
||||
'price_currency',
|
||||
'price_string',
|
||||
]
|
||||
|
||||
|
||||
@@ -164,7 +159,6 @@ class PurchaseOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer)
|
||||
'target_date',
|
||||
'notes',
|
||||
'total_price',
|
||||
'total_price_string',
|
||||
]
|
||||
|
||||
read_only_fields = [
|
||||
@@ -326,8 +320,6 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer):
|
||||
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)
|
||||
|
||||
purchase_price_currency = serializers.ChoiceField(
|
||||
@@ -387,7 +379,6 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer):
|
||||
'received',
|
||||
'purchase_price',
|
||||
'purchase_price_currency',
|
||||
'purchase_price_string',
|
||||
'destination',
|
||||
'destination_detail',
|
||||
'target_date',
|
||||
@@ -745,7 +736,6 @@ class SalesOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer):
|
||||
'shipment_date',
|
||||
'target_date',
|
||||
'total_price',
|
||||
'total_price_string',
|
||||
]
|
||||
|
||||
read_only_fields = [
|
||||
@@ -870,6 +860,7 @@ class SalesOrderLineItemSerializer(InvenTreeModelSerializer):
|
||||
part_detail = kwargs.pop('part_detail', False)
|
||||
order_detail = kwargs.pop('order_detail', False)
|
||||
allocations = kwargs.pop('allocations', False)
|
||||
customer_detail = kwargs.pop('customer_detail', False)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@@ -882,6 +873,10 @@ class SalesOrderLineItemSerializer(InvenTreeModelSerializer):
|
||||
if allocations is not True:
|
||||
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)
|
||||
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
||||
allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True)
|
||||
@@ -900,8 +895,6 @@ class SalesOrderLineItemSerializer(InvenTreeModelSerializer):
|
||||
allow_null=True
|
||||
)
|
||||
|
||||
sale_price_string = serializers.CharField(source='sale_price', read_only=True)
|
||||
|
||||
sale_price_currency = serializers.ChoiceField(
|
||||
choices=currency_code_mappings(),
|
||||
help_text=_('Sale price currency'),
|
||||
@@ -917,6 +910,7 @@ class SalesOrderLineItemSerializer(InvenTreeModelSerializer):
|
||||
'allocated',
|
||||
'allocations',
|
||||
'available_stock',
|
||||
'customer_detail',
|
||||
'quantity',
|
||||
'reference',
|
||||
'notes',
|
||||
@@ -927,7 +921,6 @@ class SalesOrderLineItemSerializer(InvenTreeModelSerializer):
|
||||
'part_detail',
|
||||
'sale_price',
|
||||
'sale_price_currency',
|
||||
'sale_price_string',
|
||||
'shipped',
|
||||
'target_date',
|
||||
]
|
||||
|
||||
@@ -58,8 +58,8 @@
|
||||
</ul>
|
||||
</div>
|
||||
{% if order.status == PurchaseOrderStatus.PENDING %}
|
||||
<button type='button' class='btn btn-outline-secondary' id='place-order' title='{% trans "Place order" %}'>
|
||||
<span class='fas fa-paper-plane icon-blue'></span>
|
||||
<button type='button' class='btn btn-primary' id='place-order' title='{% trans "Submit Order" %}'>
|
||||
<span class='fas fa-paper-plane'></span> {% trans "Submit Order" %}
|
||||
</button>
|
||||
{% elif order.status == PurchaseOrderStatus.PLACED %}
|
||||
<button type='button' class='btn btn-primary' id='receive-order' title='{% trans "Receive items" %}'>
|
||||
|
||||
@@ -5,11 +5,13 @@
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
|
||||
{% block sidebar %}
|
||||
{% include 'order/po_sidebar.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% 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-heading'>
|
||||
@@ -18,7 +20,7 @@
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
{% 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'>
|
||||
<span class='fas fa-file-upload side-icon'></span> {% trans "Upload File" %}
|
||||
</a>
|
||||
@@ -48,11 +50,13 @@
|
||||
<h4>{% trans "Extra Lines" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<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'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "Add Extra Line" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -209,6 +213,9 @@ loadPurchaseOrderLineItemTable('#po-line-table', {
|
||||
{% else %}
|
||||
allow_edit: false,
|
||||
{% endif %}
|
||||
{% if order.status == PurchaseOrderStatus.PENDING %}
|
||||
pending: true,
|
||||
{% endif %}
|
||||
{% if order.status == PurchaseOrderStatus.PLACED and roles.purchase_order.change %}
|
||||
allow_receive: true,
|
||||
{% else %}
|
||||
@@ -241,6 +248,12 @@ loadPurchaseOrderExtraLineTable(
|
||||
{
|
||||
order: {{ order.pk }},
|
||||
status: {{ order.status }},
|
||||
{% if order.is_pending %}
|
||||
pending: true,
|
||||
{% endif %}
|
||||
{% if roles.purchase_order.change %}
|
||||
allow_edit: true,
|
||||
{% endif %}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% 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-heading'>
|
||||
@@ -17,11 +18,13 @@
|
||||
<h4>{% trans "Sales Order Items" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<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'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "Add Line Item" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,10 +46,12 @@
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
{% 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'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "Add Extra Line" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -265,6 +270,12 @@
|
||||
order: {{ order.pk }},
|
||||
reference: '{{ order.reference }}',
|
||||
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 }},
|
||||
status: {{ order.status }},
|
||||
{% if roles.sales_order.change %}allow_edit: true,{% endif %}
|
||||
{% if order.is_pending %}pending: true,{% endif %}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
+119
-53
@@ -1,6 +1,7 @@
|
||||
"""Admin class definitions for the 'part' app"""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import import_export.widgets as widgets
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
@@ -15,29 +16,57 @@ from stock.models import StockLocation
|
||||
class PartResource(InvenTreeResource):
|
||||
"""Class for managing Part data import/export."""
|
||||
|
||||
# ForeignKey fields
|
||||
category = Field(attribute='category', widget=widgets.ForeignKeyWidget(models.PartCategory))
|
||||
id = Field(attribute='pk', column_name=_('Part ID'), widget=widgets.IntegerWidget())
|
||||
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))
|
||||
|
||||
default_supplier = Field(attribute='default_supplier', widget=widgets.ForeignKeyWidget(SupplierPart))
|
||||
|
||||
category_name = Field(attribute='category__name', readonly=True)
|
||||
|
||||
variant_of = Field(attribute='variant_of', widget=widgets.ForeignKeyWidget(models.Part))
|
||||
|
||||
suppliers = Field(attribute='supplier_count', readonly=True)
|
||||
# Part Attributes
|
||||
active = Field(attribute='active', column_name=_('Active'), widget=widgets.BooleanWidget())
|
||||
assembly = Field(attribute='assembly', column_name=_('Assembly'), widget=widgets.BooleanWidget())
|
||||
component = Field(attribute='component', column_name=_('Component'), widget=widgets.BooleanWidget())
|
||||
purchaseable = Field(attribute='purchaseable', column_name=_('Purchaseable'), widget=widgets.BooleanWidget())
|
||||
salable = Field(attribute='salable', column_name=_('Salable'), widget=widgets.BooleanWidget())
|
||||
is_template = Field(attribute='is_template', column_name=_('Template'), widget=widgets.BooleanWidget())
|
||||
trackable = Field(attribute='trackable', column_name=_('Trackable'), widget=widgets.BooleanWidget())
|
||||
virtual = Field(attribute='virtual', column_name=_('Virtual'), widget=widgets.BooleanWidget())
|
||||
|
||||
# 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:
|
||||
"""Metaclass definition"""
|
||||
@@ -48,7 +77,9 @@ class PartResource(InvenTreeResource):
|
||||
exclude = [
|
||||
'bom_checksum', 'bom_checked_by', 'bom_checked_date',
|
||||
'lft', 'rght', 'tree_id', 'level',
|
||||
'image',
|
||||
'metadata',
|
||||
'barcode_data', 'barcode_hash',
|
||||
]
|
||||
|
||||
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 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)
|
||||
|
||||
default_location = Field(attribute='default_location', widget=widgets.ForeignKeyWidget(StockLocation))
|
||||
# Calculated fields
|
||||
parts = Field(attribute='item_count', column_name=_('Parts'), widget=widgets.IntegerWidget(), readonly=True)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass definition"""
|
||||
@@ -112,6 +159,7 @@ class PartCategoryResource(InvenTreeResource):
|
||||
# Exclude MPTT internal model fields
|
||||
'lft', 'rght', 'tree_id', 'level',
|
||||
'metadata',
|
||||
'icon',
|
||||
]
|
||||
|
||||
def after_import(self, dataset, result, using_transactions, dry_run, **kwargs):
|
||||
@@ -160,33 +208,41 @@ class PartTestTemplateAdmin(admin.ModelAdmin):
|
||||
class BomItemResource(InvenTreeResource):
|
||||
"""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
|
||||
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
|
||||
parent_part_ipn = Field(attribute='part__IPN', readonly=True)
|
||||
sub_assembly = Field(attribute='sub_part__assembly', column_name=_('Assembly'), readonly=True)
|
||||
|
||||
# Name of the parent part
|
||||
parent_part_name = Field(attribute='part__name', readonly=True)
|
||||
def dehydrate_min_cost(self, item):
|
||||
"""Render minimum cost value for the BOM line item"""
|
||||
|
||||
# ID of the sub-part
|
||||
part_id = Field(attribute='sub_part', widget=widgets.ForeignKeyWidget(models.Part))
|
||||
min_price = item.sub_part.pricing.overall_min if item.sub_part.pricing else None
|
||||
|
||||
# IPN of the sub-part
|
||||
part_ipn = Field(attribute='sub_part__IPN', readonly=True)
|
||||
if min_price is not None:
|
||||
return float(min_price.amount) * float(item.quantity)
|
||||
|
||||
# Name of the sub-part
|
||||
part_name = Field(attribute='sub_part__name', readonly=True)
|
||||
def dehydrate_max_cost(self, item):
|
||||
"""Render maximum cost value for the BOM line item"""
|
||||
|
||||
# Description of the sub-part
|
||||
part_description = Field(attribute='sub_part__description', readonly=True)
|
||||
max_price = item.sub_part.pricing.overall_max if item.sub_part.pricing else None
|
||||
|
||||
# Is the sub-part itself an assembly?
|
||||
sub_assembly = Field(attribute='sub_part__assembly', readonly=True)
|
||||
if max_price is not None:
|
||||
return float(max_price.amount) * float(item.quantity)
|
||||
|
||||
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")
|
||||
@@ -197,34 +253,43 @@ class BomItemResource(InvenTreeResource):
|
||||
|
||||
def before_export(self, queryset, *args, **kwargs):
|
||||
"""Perform before exporting data"""
|
||||
|
||||
self.is_importing = kwargs.get('importing', False)
|
||||
self.include_pricing = kwargs.pop('include_pricing', False)
|
||||
|
||||
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."""
|
||||
fields = super().get_fields(**kwargs)
|
||||
|
||||
# If we are not generating an "import" template,
|
||||
# just return the complete list of fields
|
||||
if not getattr(self, 'is_importing', False):
|
||||
return fields
|
||||
is_importing = getattr(self, 'is_importing', False)
|
||||
include_pricing = getattr(self, 'include_pricing', False)
|
||||
|
||||
# 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
|
||||
|
||||
to_remove = [
|
||||
'level',
|
||||
'bom_id',
|
||||
'parent_part_id',
|
||||
'parent_part_ipn',
|
||||
'parent_part_name',
|
||||
'part_description',
|
||||
'sub_assembly'
|
||||
]
|
||||
|
||||
while idx < len(fields):
|
||||
|
||||
if fields[idx].column_name.lower() in to_remove:
|
||||
if fields[idx].attribute in to_remove:
|
||||
del fields[idx]
|
||||
else:
|
||||
idx += 1
|
||||
@@ -334,3 +399,4 @@ admin.site.register(models.PartCategoryParameterTemplate, PartCategoryParameterA
|
||||
admin.site.register(models.PartTestTemplate, PartTestTemplateAdmin)
|
||||
admin.site.register(models.PartSellPriceBreak, PartSellPriceBreakAdmin)
|
||||
admin.site.register(models.PartInternalPriceBreak, PartInternalPriceBreakAdmin)
|
||||
admin.site.register(models.PartPricing, PartPricingAdmin)
|
||||
|
||||
+85
-85
@@ -4,35 +4,32 @@ import functools
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
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.urls import include, path, re_path
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from django_filters import rest_framework as rest_filters
|
||||
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.exceptions import ValidationError
|
||||
from rest_framework.response import Response
|
||||
|
||||
import order.models
|
||||
from build.models import Build, BuildItem
|
||||
from common.models import InvenTreeSetting
|
||||
from company.models import Company, ManufacturerPart, SupplierPart
|
||||
from InvenTree.api import (APIDownloadMixin, AttachmentMixin,
|
||||
ListCreateDestroyAPIView)
|
||||
from InvenTree.filters import InvenTreeOrderingFilter
|
||||
from InvenTree.helpers import (DownloadFile, increment_serial_number, isNull,
|
||||
str2bool, str2int)
|
||||
from InvenTree.mixins import (CreateAPI, ListAPI, ListCreateAPI, RetrieveAPI,
|
||||
from InvenTree.mixins import (CreateAPI, CustomRetrieveUpdateDestroyAPI,
|
||||
ListAPI, ListCreateAPI, RetrieveAPI,
|
||||
RetrieveUpdateAPI, RetrieveUpdateDestroyAPI,
|
||||
UpdateAPI)
|
||||
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
|
||||
SalesOrderStatus)
|
||||
from part.admin import PartResource
|
||||
from part.admin import PartCategoryResource, PartResource
|
||||
from plugin.serializers import MetadataSerializer
|
||||
from stock.models import StockItem, StockLocation
|
||||
|
||||
@@ -44,7 +41,7 @@ from .models import (BomItem, BomItemSubstitute, Part, PartAttachment,
|
||||
PartTestTemplate)
|
||||
|
||||
|
||||
class CategoryList(ListCreateAPI):
|
||||
class CategoryList(APIDownloadMixin, ListCreateAPI):
|
||||
"""API endpoint for accessing a list of PartCategory objects.
|
||||
|
||||
- GET: Return a list of PartCategory objects
|
||||
@@ -54,6 +51,15 @@ class CategoryList(ListCreateAPI):
|
||||
queryset = PartCategory.objects.all()
|
||||
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):
|
||||
"""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."""
|
||||
|
||||
serializer_class = part_serializers.CategorySerializer
|
||||
@@ -218,6 +224,16 @@ class CategoryDetail(RetrieveUpdateDestroyAPI):
|
||||
|
||||
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):
|
||||
"""API endpoint for viewing / updating PartCategory metadata."""
|
||||
@@ -709,6 +725,27 @@ class PartMetadata(RetrieveUpdateAPI):
|
||||
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):
|
||||
"""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])
|
||||
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()
|
||||
|
||||
assembly = rest_filters.BooleanFilter()
|
||||
@@ -1052,7 +1106,7 @@ class PartList(APIDownloadMixin, ListCreateAPI):
|
||||
# Ensure the request context is passed through
|
||||
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!
|
||||
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()]
|
||||
@@ -1469,7 +1523,7 @@ class PartList(APIDownloadMixin, ListCreateAPI):
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
filters.SearchFilter,
|
||||
filters.OrderingFilter,
|
||||
InvenTreeOrderingFilter,
|
||||
]
|
||||
|
||||
ordering_fields = [
|
||||
@@ -1706,6 +1760,23 @@ class BomFilter(rest_filters.FilterSet):
|
||||
|
||||
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):
|
||||
"""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:
|
||||
- part_detail
|
||||
- sub_part_detail
|
||||
- include_pricing
|
||||
"""
|
||||
|
||||
# Do we wish to include extra detail?
|
||||
@@ -1764,12 +1834,6 @@ class BomList(ListCreateDestroyAPIView):
|
||||
except AttributeError:
|
||||
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!
|
||||
kwargs['context'] = self.get_serializer_context()
|
||||
|
||||
@@ -1839,73 +1903,6 @@ class BomList(ListCreateDestroyAPIView):
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
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
|
||||
|
||||
filter_backends = [
|
||||
@@ -2134,6 +2131,9 @@ part_api_urls = [
|
||||
# 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
|
||||
re_path(r'^.*$', PartDetail.as_view(), name='api-part-detail'),
|
||||
])),
|
||||
|
||||
+23
-1
@@ -5,7 +5,7 @@ import logging
|
||||
from django.apps import AppConfig
|
||||
from django.db.utils import OperationalError, ProgrammingError
|
||||
|
||||
from InvenTree.ready import canAppAccessDatabase
|
||||
from InvenTree.ready import canAppAccessDatabase, isImportingData
|
||||
|
||||
logger = logging.getLogger("inventree")
|
||||
|
||||
@@ -18,6 +18,7 @@ class PartConfig(AppConfig):
|
||||
"""This function is called whenever the Part app is loaded."""
|
||||
if canAppAccessDatabase():
|
||||
self.update_trackable_status()
|
||||
self.reset_part_pricing_flags()
|
||||
|
||||
def update_trackable_status(self):
|
||||
"""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
|
||||
# Exception if the database has not been migrated yet
|
||||
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
@@ -8,7 +8,8 @@ from collections import OrderedDict
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
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 .models import BomItem, Part
|
||||
@@ -42,7 +43,7 @@ def MakeBomTemplate(fmt):
|
||||
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.
|
||||
|
||||
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'.
|
||||
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.
|
||||
|
||||
kwargs:
|
||||
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.
|
||||
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.
|
||||
pricing_data (bool, optional): Include pricing data in exported BOM. Defaults to False
|
||||
|
||||
Returns:
|
||||
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):
|
||||
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)
|
||||
|
||||
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):
|
||||
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
@@ -15,7 +15,7 @@ from django.core.validators import MinValueValidator
|
||||
from django.db import models, transaction
|
||||
from django.db.models import ExpressionWrapper, F, Q, Sum, UniqueConstraint
|
||||
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.dispatch import receiver
|
||||
from django.urls import reverse
|
||||
@@ -24,6 +24,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django_cleanup import cleanup
|
||||
from djmoney.contrib.exchange.exceptions import MissingRate
|
||||
from djmoney.contrib.exchange.models import convert_money
|
||||
from djmoney.money import Money
|
||||
from jinja2 import Template
|
||||
from mptt.exceptions import InvalidMove
|
||||
from mptt.managers import TreeManager
|
||||
@@ -31,6 +32,8 @@ from mptt.models import MPTTModel, TreeForeignKey
|
||||
from stdimage.models import StdImageField
|
||||
|
||||
import common.models
|
||||
import common.settings
|
||||
import InvenTree.fields
|
||||
import InvenTree.ready
|
||||
import InvenTree.tasks
|
||||
import part.filters as part_filters
|
||||
@@ -63,31 +66,46 @@ class PartCategory(MetadataMixin, InvenTreeTree):
|
||||
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):
|
||||
"""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
|
||||
"""
|
||||
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
|
||||
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:
|
||||
if self.parent is not None:
|
||||
# Partially rebuild the tree (cheaper than a complete rebuild)
|
||||
PartCategory.objects.partial_rebuild(tree_id)
|
||||
PartCategory.objects.partial_rebuild(self.tree_id)
|
||||
else:
|
||||
PartCategory.objects.rebuild()
|
||||
|
||||
@@ -293,6 +311,7 @@ class PartManager(TreeManager):
|
||||
"""Perform default prefetch operations when accessing Part model from the database"""
|
||||
return super().get_queryset().prefetch_related(
|
||||
'category',
|
||||
'pricing_data',
|
||||
'category__parent',
|
||||
'stock_items',
|
||||
'builds',
|
||||
@@ -1634,15 +1653,25 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
||||
"""Return the number of supplier parts available for this part."""
|
||||
return self.supplier_parts.count()
|
||||
|
||||
@property
|
||||
def has_complete_bom_pricing(self):
|
||||
"""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
|
||||
def update_pricing(self):
|
||||
"""Recalculate cached pricing for this Part instance"""
|
||||
|
||||
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):
|
||||
"""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])
|
||||
)
|
||||
|
||||
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'))
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
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):
|
||||
"""Model for storing file attachments against a Part object."""
|
||||
|
||||
@@ -2871,7 +3490,7 @@ class BomItem(DataImportMixin, models.Model):
|
||||
def price_range(self, internal=False):
|
||||
"""Return the price-range for this BOM item."""
|
||||
# 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)
|
||||
|
||||
if prange is None:
|
||||
@@ -2889,6 +3508,28 @@ class BomItem(DataImportMixin, models.Model):
|
||||
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):
|
||||
"""A BomItemSubstitute provides a specification for alternative parts, which can be used in a bill of materials.
|
||||
|
||||
|
||||
+103
-79
@@ -11,10 +11,10 @@ from django.db.models.functions import Coalesce
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from djmoney.contrib.django_rest_framework import MoneyField
|
||||
from rest_framework import serializers
|
||||
from sql_util.utils import SubqueryCount, SubquerySum
|
||||
|
||||
import InvenTree.helpers
|
||||
import part.filters
|
||||
from common.settings import currency_code_default, currency_code_mappings
|
||||
from InvenTree.serializers import (DataFileExtractSerializer,
|
||||
@@ -30,8 +30,8 @@ from InvenTree.status_codes import BuildStatus
|
||||
from .models import (BomItem, BomItemSubstitute, Part, PartAttachment,
|
||||
PartCategory, PartCategoryParameterTemplate,
|
||||
PartInternalPriceBreak, PartParameter,
|
||||
PartParameterTemplate, PartRelated, PartSellPriceBreak,
|
||||
PartStar, PartTestTemplate)
|
||||
PartParameterTemplate, PartPricing, PartRelated,
|
||||
PartSellPriceBreak, PartStar, PartTestTemplate)
|
||||
|
||||
|
||||
class CategorySerializer(InvenTreeModelSerializer):
|
||||
@@ -154,8 +154,6 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
|
||||
help_text=_('Purchase currency of this stock item'),
|
||||
)
|
||||
|
||||
price_string = serializers.CharField(source='price', read_only=True)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields"""
|
||||
model = PartSellPriceBreak
|
||||
@@ -165,7 +163,6 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
|
||||
'quantity',
|
||||
'price',
|
||||
'price_currency',
|
||||
'price_string',
|
||||
]
|
||||
|
||||
|
||||
@@ -185,8 +182,6 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
|
||||
help_text=_('Purchase currency of this stock item'),
|
||||
)
|
||||
|
||||
price_string = serializers.CharField(source='price', read_only=True)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields"""
|
||||
model = PartInternalPriceBreak
|
||||
@@ -196,7 +191,6 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
|
||||
'quantity',
|
||||
'price',
|
||||
'price_currency',
|
||||
'price_string',
|
||||
]
|
||||
|
||||
|
||||
@@ -285,6 +279,7 @@ class PartBriefSerializer(InvenTreeModelSerializer):
|
||||
fields = [
|
||||
'pk',
|
||||
'IPN',
|
||||
'barcode_hash',
|
||||
'default_location',
|
||||
'name',
|
||||
'revision',
|
||||
@@ -301,6 +296,10 @@ class PartBriefSerializer(InvenTreeModelSerializer):
|
||||
'units',
|
||||
]
|
||||
|
||||
read_only_fields = [
|
||||
'barcode_hash',
|
||||
]
|
||||
|
||||
|
||||
class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
|
||||
"""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...)
|
||||
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(
|
||||
many=True,
|
||||
read_only=True,
|
||||
@@ -430,6 +433,7 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
|
||||
'allocated_to_build_orders',
|
||||
'allocated_to_sales_orders',
|
||||
'assembly',
|
||||
'barcode_hash',
|
||||
'category',
|
||||
'category_detail',
|
||||
'component',
|
||||
@@ -465,6 +469,12 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
|
||||
'units',
|
||||
'variant_of',
|
||||
'virtual',
|
||||
'pricing_min',
|
||||
'pricing_max',
|
||||
]
|
||||
|
||||
read_only_fields = [
|
||||
'barcode_hash',
|
||||
]
|
||||
|
||||
def save(self):
|
||||
@@ -493,6 +503,84 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
|
||||
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):
|
||||
"""Serializer for a PartRelated model."""
|
||||
|
||||
@@ -548,8 +636,6 @@ class BomItemSubstituteSerializer(InvenTreeModelSerializer):
|
||||
class BomItemSerializer(InvenTreeModelSerializer):
|
||||
"""Serializer for BomItem object."""
|
||||
|
||||
price_range = serializers.CharField(read_only=True)
|
||||
|
||||
quantity = InvenTreeDecimalField(required=True)
|
||||
|
||||
def validate_quantity(self, quantity):
|
||||
@@ -571,16 +657,12 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
|
||||
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)
|
||||
|
||||
# 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
|
||||
available_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)
|
||||
sub_part_detail = kwargs.pop('sub_part_detail', False)
|
||||
include_pricing = kwargs.pop('include_pricing', False)
|
||||
|
||||
super(BomItemSerializer, self).__init__(*args, **kwargs)
|
||||
|
||||
@@ -604,14 +685,6 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
if sub_part_detail is not True:
|
||||
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
|
||||
def setup_eager_loading(queryset):
|
||||
"""Prefetch against the provided queryset to speed up database access"""
|
||||
@@ -633,7 +706,6 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
'substitutes__part__stock_items',
|
||||
)
|
||||
|
||||
queryset = queryset.prefetch_related('sub_part__supplier_parts__pricebreaks')
|
||||
return queryset
|
||||
|
||||
@staticmethod
|
||||
@@ -707,51 +779,6 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
|
||||
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:
|
||||
"""Metaclass defining serializer fields"""
|
||||
model = BomItem
|
||||
@@ -765,16 +792,13 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
'pk',
|
||||
'part',
|
||||
'part_detail',
|
||||
'purchase_price_avg',
|
||||
'purchase_price_max',
|
||||
'purchase_price_min',
|
||||
'purchase_price_range',
|
||||
'pricing_min',
|
||||
'pricing_max',
|
||||
'quantity',
|
||||
'reference',
|
||||
'sub_part',
|
||||
'sub_part_detail',
|
||||
'substitutes',
|
||||
'price_range',
|
||||
'validated',
|
||||
|
||||
# Annotated fields describing available quantity
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
"""Background task definitions for the 'part' app"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import common.models
|
||||
import common.notifications
|
||||
import common.settings
|
||||
import InvenTree.helpers
|
||||
import InvenTree.tasks
|
||||
import part.models
|
||||
from InvenTree.tasks import ScheduledTask, scheduled_task
|
||||
|
||||
logger = logging.getLogger("inventree")
|
||||
|
||||
@@ -53,3 +57,70 @@ def notify_low_stock_if_required(part: part.models.Part):
|
||||
notify_low_stock,
|
||||
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()
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% 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 %}
|
||||
{% include "admin_button.html" with url=url %}
|
||||
{% endif %}
|
||||
|
||||
@@ -131,11 +131,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if part.purchaseable or part.salable %}
|
||||
<div class='panel panel-hidden' id='panel-pricing'>
|
||||
{% include "part/prices.html" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class='panel panel-hidden' id='panel-part-notes'>
|
||||
<div class='panel-heading'>
|
||||
@@ -878,162 +876,7 @@
|
||||
});
|
||||
|
||||
onPanelLoad('pricing', function() {
|
||||
{% default_currency as currency %}
|
||||
|
||||
// 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 %}
|
||||
{% include "part/pricing_javascript.html" %}
|
||||
});
|
||||
|
||||
enableSidebar('part');
|
||||
|
||||
@@ -323,6 +323,21 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
</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 %}
|
||||
{% if part.trackable and sn %}
|
||||
<tr>
|
||||
|
||||
@@ -76,14 +76,6 @@
|
||||
{% 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 %}
|
||||
{% else %}
|
||||
<tr>
|
||||
|
||||
@@ -27,10 +27,8 @@
|
||||
{% trans "Used In" as text %}
|
||||
{% include "sidebar_item.html" with label="used-in" text=text icon="fa-layer-group" %}
|
||||
{% endif %}
|
||||
{% if part.purchaseable or part.salable %}
|
||||
{% trans "Pricing" as text %}
|
||||
{% include "sidebar_item.html" with label="pricing" text=text icon="fa-dollar-sign" %}
|
||||
{% endif %}
|
||||
{% if part.purchaseable and roles.purchase_order.view %}
|
||||
{% trans "Suppliers" as text %}
|
||||
{% include "sidebar_item.html" with label="suppliers" text=text icon="fa-building" %}
|
||||
|
||||
@@ -5,252 +5,299 @@
|
||||
|
||||
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
||||
|
||||
{% if show_price_history %}
|
||||
<a class="anchor" id="overview"></a>
|
||||
<div class='panel-heading'>
|
||||
<h4>{% trans "Pricing Information" %}</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>
|
||||
|
||||
{% default_currency as currency %}
|
||||
<div class='panel-content'>
|
||||
|
||||
<div class="row">
|
||||
<a class="anchor" id="overview"></a>
|
||||
<div class="col col-md-6">
|
||||
<h4>{% trans "Pricing ranges" %}</h4>
|
||||
{% 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'>
|
||||
{% if part.supplier_count > 0 %}
|
||||
{% if min_total_buy_price %}
|
||||
<col width='25'>
|
||||
<thead>
|
||||
<tr>
|
||||
<td><strong>{% trans 'Supplier Pricing' %}</strong>
|
||||
<a href="#supplier-cost" title='{% trans "Show supplier cost" %}'><span class="fas fa-search-dollar"></span></a>
|
||||
<a href="#purchase-price" title='{% trans "Show purchase price" %}'><span class="fas fa-chart-bar"></span></a>
|
||||
</td>
|
||||
<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>
|
||||
<th></th>
|
||||
<th>{% trans "Price Category" %}</th>
|
||||
<th>{% trans "Minimum" %}</th>
|
||||
<th>{% trans "Maximum" %}</th>
|
||||
</tr>
|
||||
{% if quantity > 1 %}
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<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>
|
||||
<td>
|
||||
{% if show_internal_price and roles.sales_order.view %}
|
||||
<a href='#internal-cost'>
|
||||
<span class='fas fa-dollar-sign'></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<th>
|
||||
{% 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>
|
||||
<td>
|
||||
{% if roles.purchase_order.view %}
|
||||
<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>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if part.assembly %}
|
||||
<tr>
|
||||
<td colspan='4'>
|
||||
<span class='warning-msg'><em>{% trans 'No supplier pricing available' %}</em></span>
|
||||
<td>
|
||||
{% if part.has_bom %}
|
||||
<a href='#bom-cost'>
|
||||
<span class='fas fa-tools'></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</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>
|
||||
<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 %}
|
||||
{% endif %}
|
||||
|
||||
{% if not part.has_complete_bom_pricing %}
|
||||
{% if part.is_template %}
|
||||
<tr>
|
||||
<td colspan='4'>
|
||||
<span class='warning-msg'><em>{% trans 'Note: BOM pricing is incomplete for this part' %}</em></span>
|
||||
</td>
|
||||
<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 %}
|
||||
|
||||
{% if min_total_bom_price or min_total_bom_purchase_price %}
|
||||
{% else %}
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td colspan='4'>
|
||||
<span class='warning-msg'><em>{% trans 'No BOM pricing available' %}</em></span>
|
||||
</td>
|
||||
<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>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if show_internal_price and roles.sales_order.view %}
|
||||
{% if total_internal_part_price %}
|
||||
<tr>
|
||||
<td><strong>{% trans 'Internal Price' %}</strong></td>
|
||||
<td>{% trans 'Unit Cost' %}</td>
|
||||
<td colspan='2'>{% include "price.html" with price=unit_internal_part_price %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>{% trans 'Total Cost' %}</td>
|
||||
<td colspan='2'>{% include "price.html" with price=total_internal_part_price %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if total_part_price %}
|
||||
<tr>
|
||||
<td><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>{% trans 'Unit Cost' %}</td>
|
||||
<td colspan='2'>{% include "price.html" with price=unit_part_price %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>{% trans 'Total Cost' %}</td>
|
||||
<td colspan='2'>{% include "price.html" with price=total_part_price %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</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>
|
||||
|
||||
{% if min_unit_buy_price or min_unit_bom_price or min_unit_bom_purchase_price %}
|
||||
{% else %}
|
||||
<div class='alert alert-danger alert-block'>
|
||||
{% trans 'No pricing information is available for this part.' %}
|
||||
</div>
|
||||
<div class='alert alert-block alert-info'>
|
||||
{% trans "Sale price data is not available for this part" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</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>
|
||||
{% else %}
|
||||
<div class='alert alert-warning alert-block'>
|
||||
{% trans "Price range data is not available for this part." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</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. 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>
|
||||
{% else %}
|
||||
<div class='alert alert-danger alert-block'>
|
||||
{% trans 'No stock pricing history is available for this part.' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if show_internal_price and roles.sales_order.view %}
|
||||
<a class="anchor" id="internal-cost"></a>
|
||||
<div class='panel-heading'>
|
||||
<h4>{% trans "Internal Cost" %}
|
||||
<a href="#overview" title='{% trans "Jump to overview" %}'><span class="fas fa-level-up-alt"></span></a>
|
||||
</h4>
|
||||
<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>
|
||||
</h4>
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
<button class='btn btn-success' id='new-internal-price-break' type='button'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "Add Internal Price Break" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='panel-content'>
|
||||
<div class="row full-height">
|
||||
<div class="col col-md-8">
|
||||
<div class="col col-md-6">
|
||||
<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'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "Add Internal Price Break" %}
|
||||
</button>
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
|
||||
<table class='table table-striped table-condensed' id='internal-price-break-table' data-toolbar='#internal-price-break-toolbar'
|
||||
data-sort-name="quantity" data-sort-order="asc">
|
||||
<table class='table table-striped table-condensed' id='internal-price-break-table'>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if part.has_bom and roles.sales_order.view %}
|
||||
<a class="anchor" id="bom-cost"></a>
|
||||
{% if part.purchaseable and roles.purchase_order.view %}
|
||||
<a class="anchor" id="purchase-price-history"></a>
|
||||
<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>
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div class='panel-content'>
|
||||
<div class="row">
|
||||
<div class='row full-height'>
|
||||
<div class="col col-md-6">
|
||||
<table class='table table-bom table-condensed' data-toolbar="#button-toolbar" id='bom-pricing-table'></table>
|
||||
</div>
|
||||
|
||||
{% if part.bom_count > 0 %}
|
||||
<div class="col col-md-6">
|
||||
<h4>{% trans 'BOM Pricing' %}</h4>
|
||||
<div style="max-width: 99%;">
|
||||
<canvas id="BomChart"></canvas>
|
||||
<div style="max-width: 99%; height: 100%;">
|
||||
<canvas id="part-supplier-pricing-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="col col-md-6">
|
||||
<table class='table table-striped table-condensed' id='part-supplier-pricing-table'>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
@@ -258,50 +305,52 @@
|
||||
{% if part.salable and roles.sales_order.view %}
|
||||
<a class="anchor" id="sale-cost"></a>
|
||||
<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>
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div class='panel-content'>
|
||||
<div class="row full-height">
|
||||
<div class="col col-md-8">
|
||||
<div class="col col-md-6">
|
||||
<div style="max-width: 99%; height: 100%;">
|
||||
<canvas id="SalePriceBreakChart"></canvas>
|
||||
<canvas id="part-sales-history-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col col-md-4">
|
||||
<div id='price-break-toolbar' class='btn-group'>
|
||||
<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">
|
||||
<div class="col col-md-6">
|
||||
<table class='table table-striped table-condensed' id='part-sales-history-table'>
|
||||
</table>
|
||||
</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 %}
|
||||
|
||||
@@ -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 sys
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from django import template
|
||||
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.translation import gettext_lazy as _
|
||||
|
||||
import moneyed.localization
|
||||
|
||||
import InvenTree.helpers
|
||||
from common.models import ColorTheme, InvenTreeSetting, InvenTreeUserSetting
|
||||
from common.settings import currency_code_default
|
||||
@@ -37,6 +40,12 @@ def define(value, *args, **kwargs):
|
||||
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)
|
||||
def render_date(context, date_object):
|
||||
"""Renders a date according to the preference of the provided user.
|
||||
@@ -94,10 +103,34 @@ def render_date(context, date_object):
|
||||
return date_object
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def decimal(x, *args, **kwargs):
|
||||
"""Simplified rendering of a decimal number."""
|
||||
return InvenTree.helpers.decimal2string(x)
|
||||
@register.simple_tag
|
||||
def render_currency(money, decimal_places=None, include_symbol=True):
|
||||
"""Render a currency / Money object"""
|
||||
|
||||
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()
|
||||
|
||||
+174
-15
@@ -1,6 +1,7 @@
|
||||
"""Unit tests for the various part API endpoints"""
|
||||
|
||||
from decimal import Decimal
|
||||
from enum import IntEnum
|
||||
from random import randint
|
||||
|
||||
from django.urls import reverse
|
||||
@@ -294,6 +295,114 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
|
||||
|
||||
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):
|
||||
"""Tests for the various OPTIONS endpoints in the /part/ API.
|
||||
@@ -1073,17 +1182,17 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
url = reverse('api-part-list')
|
||||
|
||||
required_cols = [
|
||||
'id',
|
||||
'name',
|
||||
'description',
|
||||
'in_stock',
|
||||
'category_name',
|
||||
'keywords',
|
||||
'is_template',
|
||||
'virtual',
|
||||
'trackable',
|
||||
'active',
|
||||
'notes',
|
||||
'Part ID',
|
||||
'Part Name',
|
||||
'Part Description',
|
||||
'In Stock',
|
||||
'Category Name',
|
||||
'Keywords',
|
||||
'Template',
|
||||
'Virtual',
|
||||
'Trackable',
|
||||
'Active',
|
||||
'Notes',
|
||||
'creation_date',
|
||||
]
|
||||
|
||||
@@ -1108,16 +1217,16 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
)
|
||||
|
||||
for row in data:
|
||||
part = Part.objects.get(pk=row['id'])
|
||||
part = Part.objects.get(pk=row['Part ID'])
|
||||
|
||||
if part.IPN:
|
||||
self.assertEqual(part.IPN, row['IPN'])
|
||||
|
||||
self.assertEqual(part.name, row['name'])
|
||||
self.assertEqual(part.description, row['description'])
|
||||
self.assertEqual(part.name, row['Part Name'])
|
||||
self.assertEqual(part.description, row['Part Description'])
|
||||
|
||||
if part.category:
|
||||
self.assertEqual(part.category.name, row['category_name'])
|
||||
self.assertEqual(part.category.name, row['Category Name'])
|
||||
|
||||
|
||||
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']))
|
||||
|
||||
|
||||
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):
|
||||
"""Tests to ensure that the various aggregation annotations are working correctly..."""
|
||||
|
||||
|
||||
@@ -58,21 +58,20 @@ class BomExportTest(InvenTreeTestCase):
|
||||
break
|
||||
|
||||
expected = [
|
||||
'part_id',
|
||||
'part_ipn',
|
||||
'part_name',
|
||||
'quantity',
|
||||
'Part ID',
|
||||
'Part IPN',
|
||||
'Quantity',
|
||||
'Reference',
|
||||
'Note',
|
||||
'optional',
|
||||
'overage',
|
||||
'reference',
|
||||
'note',
|
||||
'inherited',
|
||||
'allow_variants',
|
||||
]
|
||||
|
||||
# Ensure all the expected headers are in the provided file
|
||||
for header in expected:
|
||||
self.assertTrue(header in headers)
|
||||
self.assertIn(header, headers)
|
||||
|
||||
def test_export_csv(self):
|
||||
"""Test BOM download in CSV format."""
|
||||
@@ -106,22 +105,22 @@ class BomExportTest(InvenTreeTestCase):
|
||||
break
|
||||
|
||||
expected = [
|
||||
'level',
|
||||
'bom_id',
|
||||
'parent_part_id',
|
||||
'parent_part_ipn',
|
||||
'parent_part_name',
|
||||
'part_id',
|
||||
'part_ipn',
|
||||
'part_name',
|
||||
'part_description',
|
||||
'sub_assembly',
|
||||
'quantity',
|
||||
'BOM Level',
|
||||
'BOM Item ID',
|
||||
'Parent ID',
|
||||
'Parent IPN',
|
||||
'Parent Name',
|
||||
'Part ID',
|
||||
'Part IPN',
|
||||
'Part Name',
|
||||
'Description',
|
||||
'Assembly',
|
||||
'Quantity',
|
||||
'optional',
|
||||
'consumable',
|
||||
'overage',
|
||||
'reference',
|
||||
'note',
|
||||
'Reference',
|
||||
'Note',
|
||||
'inherited',
|
||||
'allow_variants',
|
||||
'Default Location',
|
||||
@@ -131,10 +130,10 @@ class BomExportTest(InvenTreeTestCase):
|
||||
]
|
||||
|
||||
for header in expected:
|
||||
self.assertTrue(header in headers)
|
||||
self.assertIn(header, headers)
|
||||
|
||||
for header in headers:
|
||||
self.assertTrue(header in expected)
|
||||
self.assertIn(header, expected)
|
||||
|
||||
def test_export_xls(self):
|
||||
"""Test BOM download in XLS format."""
|
||||
|
||||
@@ -148,7 +148,7 @@ class CategoryTest(TestCase):
|
||||
def test_parameters(self):
|
||||
"""Test that the Category parameters are correctly fetched."""
|
||||
# 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)
|
||||
fasteners = self.fasteners.prefetch_parts_parameters()
|
||||
# Iterate through all parts and parameters
|
||||
|
||||
@@ -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
@@ -11,10 +11,6 @@ from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
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.models import InvenTreeSetting
|
||||
from common.views import FileManagementAjaxView, FileManagementFormView
|
||||
@@ -22,7 +18,6 @@ from company.models import SupplierPart
|
||||
from InvenTree.helpers import str2bool
|
||||
from InvenTree.views import (AjaxUpdateView, AjaxView, InvenTreeRoleMixin,
|
||||
QRCodeView)
|
||||
from order.models import PurchaseOrderLineItem
|
||||
from plugin.views import InvenTreePluginViewMixin
|
||||
from stock.models import StockItem, StockLocation
|
||||
|
||||
@@ -292,17 +287,6 @@ class PartDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
||||
|
||||
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
|
||||
|
||||
def get_quantity(self):
|
||||
@@ -313,113 +297,6 @@ class PartDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
||||
"""Return the Part instance associated with this view"""
|
||||
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):
|
||||
"""Returns initials for form."""
|
||||
return {'quantity': self.get_quantity()}
|
||||
@@ -573,6 +450,8 @@ class BomDownload(AjaxView):
|
||||
|
||||
manufacturer_data = str2bool(request.GET.get('manufacturer_data', False))
|
||||
|
||||
pricing_data = str2bool(request.GET.get('pricing_data', False))
|
||||
|
||||
levels = request.GET.get('levels', None)
|
||||
|
||||
if levels is not None:
|
||||
@@ -596,6 +475,7 @@ class BomDownload(AjaxView):
|
||||
stock_data=stock_data,
|
||||
supplier_data=supplier_data,
|
||||
manufacturer_data=manufacturer_data,
|
||||
pricing_data=pricing_data,
|
||||
)
|
||||
|
||||
def get_data(self):
|
||||
|
||||
@@ -13,7 +13,7 @@ class ActionMixinTests(TestCase):
|
||||
ACTION_RETURN = 'a action was performed'
|
||||
|
||||
def setUp(self):
|
||||
"""Setup enviroment for tests.
|
||||
"""Setup environment for tests.
|
||||
|
||||
Contains multiple sample plugins that are used in the tests
|
||||
"""
|
||||
|
||||
@@ -121,8 +121,10 @@ def allow_table_event(table_name):
|
||||
|
||||
ignore_tables = [
|
||||
'common_notificationentry',
|
||||
'common_notificationmessage',
|
||||
'common_webhookendpoint',
|
||||
'common_webhookmessage',
|
||||
'part_partpricing',
|
||||
]
|
||||
|
||||
if table_name in ignore_tables:
|
||||
|
||||
@@ -311,7 +311,7 @@ class PluginsRegistry:
|
||||
return collected_plugins
|
||||
|
||||
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:
|
||||
logger.info('Plugin file was already checked')
|
||||
return True
|
||||
|
||||
@@ -198,7 +198,7 @@ class RegistryTests(TestCase):
|
||||
def run_package_test(self, directory):
|
||||
"""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}
|
||||
with mock.patch.dict(os.environ, envs):
|
||||
# Reload to redicsover plugins
|
||||
|
||||
@@ -18,6 +18,20 @@ register = template.Library()
|
||||
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()
|
||||
def asset(filename):
|
||||
"""Return fully-qualified path for an upload report asset file.
|
||||
|
||||
@@ -29,6 +29,19 @@ class ReportTagTest(TestCase):
|
||||
"""Enable or disable debug mode for reports"""
|
||||
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):
|
||||
"""Tests for asset files"""
|
||||
|
||||
|
||||
+37
-33
@@ -1,6 +1,7 @@
|
||||
"""Admin for stock app."""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import import_export.widgets as widgets
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
@@ -19,9 +20,15 @@ from .models import (StockItem, StockItemAttachment, StockItemTestResult,
|
||||
class LocationResource(InvenTreeResource):
|
||||
"""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:
|
||||
"""Metaclass options."""
|
||||
@@ -35,6 +42,8 @@ class LocationResource(InvenTreeResource):
|
||||
# Exclude MPTT internal model fields
|
||||
'lft', 'rght', 'tree_id', 'level',
|
||||
'metadata',
|
||||
'barcode_data', 'barcode_hash',
|
||||
'owner', 'icon',
|
||||
]
|
||||
|
||||
def after_import(self, dataset, result, using_transactions, dry_run, **kwargs):
|
||||
@@ -71,39 +80,32 @@ class LocationAdmin(ImportExportModelAdmin):
|
||||
class StockItemResource(InvenTreeResource):
|
||||
"""Class for managing StockItem data import/export."""
|
||||
|
||||
# Custom managers for ForeignKey fields
|
||||
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
|
||||
|
||||
part_name = Field(attribute='part__full_name', readonly=True)
|
||||
|
||||
supplier_part = Field(attribute='supplier_part', widget=widgets.ForeignKeyWidget(SupplierPart))
|
||||
|
||||
supplier = Field(attribute='supplier_part__supplier__id', readonly=True)
|
||||
|
||||
customer = Field(attribute='customer', widget=widgets.ForeignKeyWidget(Company))
|
||||
|
||||
supplier_name = Field(attribute='supplier_part__supplier__name', readonly=True)
|
||||
|
||||
status_label = Field(attribute='status_label', readonly=True)
|
||||
|
||||
location = Field(attribute='location', widget=widgets.ForeignKeyWidget(StockLocation))
|
||||
|
||||
location_name = Field(attribute='location__name', readonly=True)
|
||||
|
||||
belongs_to = Field(attribute='belongs_to', widget=widgets.ForeignKeyWidget(StockItem))
|
||||
|
||||
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))
|
||||
id = Field(attribute='pk', column_name=_('Stock Item ID'))
|
||||
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)
|
||||
quantity = Field(attribute='quantity', column_name=_('Quantity'))
|
||||
serial = Field(attribute='serial', column_name=_('Serial'))
|
||||
batch = Field(attribute='batch', column_name=_('Batch'))
|
||||
status_label = Field(attribute='status_label', column_name=_('Status'), 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)
|
||||
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', column_name=_('Supplier Name'), readonly=True)
|
||||
customer = Field(attribute='customer', column_name=_('Customer ID'), widget=widgets.ForeignKeyWidget(Company))
|
||||
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))
|
||||
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))
|
||||
purchase_order = Field(attribute='purchase_order', column_name=_('Purchase Order ID'), widget=widgets.ForeignKeyWidget(PurchaseOrder))
|
||||
packaging = Field(attribute='packaging', column_name=_('Packaging'))
|
||||
link = Field(attribute='link', column_name=_('Link'))
|
||||
notes = Field(attribute='notes', column_name=_('Notes'))
|
||||
|
||||
# Date management
|
||||
updated = Field(attribute='updated', widget=widgets.DateWidget())
|
||||
|
||||
stocktake_date = Field(attribute='stocktake_date', 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())
|
||||
expiry_date = Field(attribute='expiry_date', column_name=_('Expiry Date'), widget=widgets.DateWidget())
|
||||
|
||||
def after_import(self, dataset, result, using_transactions, dry_run, **kwargs):
|
||||
"""Rebuild after import to keep tree intact."""
|
||||
@@ -125,6 +127,8 @@ class StockItemResource(InvenTreeResource):
|
||||
'lft', 'rght', 'tree_id', 'level',
|
||||
# Exclude internal fields
|
||||
'serial_int', 'metadata',
|
||||
'barcode_hash', 'barcode_data',
|
||||
'owner',
|
||||
]
|
||||
|
||||
|
||||
|
||||
+24
-4
@@ -27,14 +27,15 @@ from InvenTree.api import (APIDownloadMixin, AttachmentMixin,
|
||||
from InvenTree.filters import InvenTreeOrderingFilter
|
||||
from InvenTree.helpers import (DownloadFile, extract_serial_numbers, isNull,
|
||||
str2bool, str2int)
|
||||
from InvenTree.mixins import (CreateAPI, ListAPI, ListCreateAPI, RetrieveAPI,
|
||||
from InvenTree.mixins import (CreateAPI, CustomRetrieveUpdateDestroyAPI,
|
||||
ListAPI, ListCreateAPI, RetrieveAPI,
|
||||
RetrieveUpdateAPI, RetrieveUpdateDestroyAPI)
|
||||
from order.models import PurchaseOrder, SalesOrder, SalesOrderAllocation
|
||||
from order.serializers import PurchaseOrderSerializer
|
||||
from part.models import BomItem, Part, PartCategory
|
||||
from part.serializers import PartBriefSerializer
|
||||
from plugin.serializers import MetadataSerializer
|
||||
from stock.admin import StockItemResource
|
||||
from stock.admin import LocationResource, StockItemResource
|
||||
from stock.models import (StockItem, StockItemAttachment, StockItemTestResult,
|
||||
StockItemTracking, StockLocation)
|
||||
|
||||
@@ -214,7 +215,7 @@ class StockMerge(CreateAPI):
|
||||
return ctx
|
||||
|
||||
|
||||
class StockLocationList(ListCreateAPI):
|
||||
class StockLocationList(APIDownloadMixin, ListCreateAPI):
|
||||
"""API endpoint for list view of StockLocation objects.
|
||||
|
||||
- GET: Return list of StockLocation objects
|
||||
@@ -224,6 +225,15 @@ class StockLocationList(ListCreateAPI):
|
||||
queryset = StockLocation.objects.all()
|
||||
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):
|
||||
"""Return annotated queryset for the StockLocationList endpoint"""
|
||||
|
||||
@@ -1357,7 +1367,7 @@ class LocationMetadata(RetrieveUpdateAPI):
|
||||
queryset = StockLocation.objects.all()
|
||||
|
||||
|
||||
class LocationDetail(RetrieveUpdateDestroyAPI):
|
||||
class LocationDetail(CustomRetrieveUpdateDestroyAPI):
|
||||
"""API endpoint for detail view of StockLocation object.
|
||||
|
||||
- GET: Return a single StockLocation object
|
||||
@@ -1375,6 +1385,16 @@ class LocationDetail(RetrieveUpdateDestroyAPI):
|
||||
queryset = StockSerializers.LocationSerializer.annotate_queryset(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 = [
|
||||
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
@@ -43,9 +43,36 @@ class StockLocation(InvenTreeBarcodeMixin, MetadataMixin, InvenTreeTree):
|
||||
"""Organization tree for StockItem objects.
|
||||
|
||||
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):
|
||||
"""Custom model deletion routine, which updates any child locations or items.
|
||||
|
||||
@@ -53,24 +80,13 @@ class StockLocation(InvenTreeBarcodeMixin, MetadataMixin, InvenTreeTree):
|
||||
"""
|
||||
with transaction.atomic():
|
||||
|
||||
parent = self.parent
|
||||
tree_id = self.tree_id
|
||||
self.delete_recursive(**dict(delete_stock_items=kwargs.get('delete_stock_items', False),
|
||||
delete_sub_locations=kwargs.get('delete_sub_locations', False),
|
||||
parent_category=self.parent))
|
||||
|
||||
# Update each stock item in the stock location
|
||||
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:
|
||||
if self.parent is not None:
|
||||
# Partially rebuild the tree (cheaper than a complete rebuild)
|
||||
StockLocation.objects.partial_rebuild(tree_id)
|
||||
StockLocation.objects.partial_rebuild(self.tree_id)
|
||||
else:
|
||||
StockLocation.objects.rebuild()
|
||||
|
||||
@@ -656,7 +672,7 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
||||
|
||||
link = InvenTreeURLField(
|
||||
verbose_name=_('External Link'),
|
||||
blank=True, max_length=200,
|
||||
blank=True,
|
||||
help_text=_("Link to external URL")
|
||||
)
|
||||
|
||||
@@ -735,7 +751,7 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
||||
|
||||
purchase_price = InvenTreeModelMoneyField(
|
||||
max_digits=19,
|
||||
decimal_places=4,
|
||||
decimal_places=6,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Purchase Price'),
|
||||
|
||||
@@ -65,6 +65,10 @@ class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
'barcode_hash',
|
||||
]
|
||||
|
||||
read_only_fields = [
|
||||
'barcode_hash',
|
||||
]
|
||||
|
||||
def validate_serial(self, value):
|
||||
"""Make sure serial is not to big."""
|
||||
if abs(extract_int(value)) > 0x7fffffff:
|
||||
@@ -167,7 +171,7 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
|
||||
purchase_price = InvenTree.serializers.InvenTreeMoneySerializer(
|
||||
label=_('Purchase Price'),
|
||||
max_digits=19, decimal_places=4,
|
||||
max_digits=19, decimal_places=6,
|
||||
allow_null=True,
|
||||
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'),
|
||||
)
|
||||
|
||||
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)
|
||||
sales_order_reference = serializers.CharField(source='sales_order.reference', read_only=True)
|
||||
|
||||
@@ -249,7 +243,6 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
'updated',
|
||||
'purchase_price',
|
||||
'purchase_price_currency',
|
||||
'purchase_price_string',
|
||||
]
|
||||
|
||||
"""
|
||||
@@ -258,6 +251,7 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
"""
|
||||
read_only_fields = [
|
||||
'allocated',
|
||||
'barcode_hash',
|
||||
'stocktake_date',
|
||||
'stocktake_user',
|
||||
'updated',
|
||||
@@ -602,6 +596,7 @@ class LocationSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
model = StockLocation
|
||||
fields = [
|
||||
'pk',
|
||||
'barcode_hash',
|
||||
'url',
|
||||
'name',
|
||||
'level',
|
||||
@@ -613,6 +608,10 @@ class LocationSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
'icon',
|
||||
]
|
||||
|
||||
read_only_fields = [
|
||||
'barcode_hash',
|
||||
]
|
||||
|
||||
|
||||
class StockItemAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSerializer):
|
||||
"""Serializer for StockItemAttachment model."""
|
||||
|
||||
+127
-10
@@ -3,6 +3,7 @@
|
||||
import io
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from enum import IntEnum
|
||||
|
||||
import django.http
|
||||
from django.urls import reverse
|
||||
@@ -15,6 +16,7 @@ import part.models
|
||||
from common.models import InvenTreeSetting
|
||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
from InvenTree.status_codes import StockStatus
|
||||
from part.models import Part
|
||||
from stock.models import StockItem, StockItemTestResult, StockLocation
|
||||
|
||||
|
||||
@@ -37,6 +39,7 @@ class StockAPITestCase(InvenTreeAPITestCase):
|
||||
'stock.add',
|
||||
'stock_location.change',
|
||||
'stock_location.add',
|
||||
'stock_location.delete',
|
||||
'stock.delete',
|
||||
]
|
||||
|
||||
@@ -107,6 +110,121 @@ class StockLocationTest(StockAPITestCase):
|
||||
response = self.client.post(self.list_url, data, format='json')
|
||||
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):
|
||||
"""Tests for the StockItem API LIST endpoint."""
|
||||
@@ -320,12 +438,13 @@ class StockItemListTest(StockAPITestCase):
|
||||
|
||||
# Expected headers
|
||||
headers = [
|
||||
'part',
|
||||
'customer',
|
||||
'location',
|
||||
'parent',
|
||||
'quantity',
|
||||
'status',
|
||||
'Part ID',
|
||||
'Customer ID',
|
||||
'Location ID',
|
||||
'Location Name',
|
||||
'Parent ID',
|
||||
'Quantity',
|
||||
'Status',
|
||||
]
|
||||
|
||||
for h in headers:
|
||||
@@ -567,9 +686,8 @@ class StockItemTest(StockAPITestCase):
|
||||
data = self.get(url, expected_code=200).data
|
||||
|
||||
# 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_string'], 'A$123.0000')
|
||||
|
||||
# Update just the amount
|
||||
data = self.patch(
|
||||
@@ -580,7 +698,7 @@ class StockItemTest(StockAPITestCase):
|
||||
expected_code=200
|
||||
).data
|
||||
|
||||
self.assertEqual(data['purchase_price'], '456.0000')
|
||||
self.assertEqual(data['purchase_price'], '456.000000')
|
||||
self.assertEqual(data['purchase_price_currency'], 'AUD')
|
||||
|
||||
# Update the currency
|
||||
@@ -604,7 +722,6 @@ class StockItemTest(StockAPITestCase):
|
||||
).data
|
||||
|
||||
self.assertEqual(data['purchase_price'], None)
|
||||
self.assertEqual(data['purchase_price_string'], '-')
|
||||
|
||||
# Invalid currency code
|
||||
data = self.patch(
|
||||
|
||||
@@ -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_CATEGORY_STARRED' user=request.user as setting_category_starred %}
|
||||
{% 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 %}
|
||||
|
||||
{% 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_LOW' user=request.user as setting_stock_low %}
|
||||
{% settings_value 'HOMEPAGE_STOCK_DEPLETED' user=request.user as setting_stock_depleted %}
|
||||
{% settings_value 'HOMEPAGE_STOCK_NEEDED' user=request.user as setting_stock_needed %}
|
||||
{% settings_value 'HOMEPAGE_SHOW_STOCK_DEPLETED' user=request.user as setting_stock_depleted %}
|
||||
{% settings_value 'HOMEPAGE_BUILD_STOCK_NEEDED' user=request.user as setting_stock_needed %}
|
||||
{% settings_value "STOCK_ENABLE_EXPIRY" as expiry %}
|
||||
{% if expiry %}
|
||||
{% settings_value 'HOMEPAGE_STOCK_EXPIRED' user=request.user as setting_stock_expired %}
|
||||
@@ -306,6 +306,17 @@ loadSalesOrderTable("#table-so-overdue", {
|
||||
|
||||
{% 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(
|
||||
'index',
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user