Merge branch 'master' into partial-shipment

# Conflicts:
#	InvenTree/build/serializers.py
#	InvenTree/order/templates/order/so_sidebar.html
This commit is contained in:
Oliver 2021-11-26 08:25:51 +11:00
commit 68e2b0850b
129 changed files with 66929 additions and 54543 deletions

View File

@ -0,0 +1,37 @@
name: Check Translations
on:
push:
branches:
- l10
pull_request:
branches:
- l10
jobs:
check:
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INVENTREE_DB_NAME: './test_db.sqlite'
INVENTREE_DB_ENGINE: django.db.backends.sqlite3
INVENTREE_DEBUG: info
INVENTREE_MEDIA_ROOT: ./media
INVENTREE_STATIC_ROOT: ./static
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Install Dependencies
run: |
sudo apt-get update
sudo apt-get install gettext
pip3 install invoke
invoke install
- name: Test Translations
run: invoke translate
- name: Check Migration Files
run: python3 ci/check_migration_files.py

View File

@ -46,7 +46,7 @@ class InvenTreeAPITestCase(APITestCase):
self.user.is_staff = True
self.user.save()
for role in self.roles:
self.assignRole(role)

View File

@ -1,9 +1,8 @@
"""
Pull rendered copies of the templated
Pull rendered copies of the templated
"""
from django.http import response
from django.test import TestCase, testcases
from django.test import TestCase
from django.contrib.auth import get_user_model
import os

View File

@ -53,7 +53,7 @@ class InvenTreeModelMoneyField(ModelMoneyField):
"""
Custom MoneyField for clean migrations while using dynamic currency settings
"""
def __init__(self, **kwargs):
# detect if creating migration
if 'migrate' in sys.argv or 'makemigrations' in sys.argv:

View File

@ -38,7 +38,7 @@ class InvenTreeOrderingFilter(OrderingFilter):
ordering = []
for field in ordering_initial:
reverse = field.startswith('-')
if reverse:
@ -52,7 +52,7 @@ class InvenTreeOrderingFilter(OrderingFilter):
"""
Potentially, a single field could be "aliased" to multiple field,
(For example to enforce a particular ordering sequence)
e.g. to filter first by the integer value...

View File

@ -36,7 +36,7 @@ class Command(BaseCommand):
img = model.image
url = img.thumbnail.name
loc = os.path.join(settings.MEDIA_ROOT, url)
if not os.path.exists(loc):
logger.info(f"Generating thumbnail image for '{img}'")

View File

@ -31,7 +31,7 @@ class InvenTreeMetadata(SimpleMetadata):
"""
def determine_metadata(self, request, view):
self.request = request
self.view = view
@ -98,7 +98,7 @@ class InvenTreeMetadata(SimpleMetadata):
Override get_serializer_info so that we can add 'default' values
to any fields whose Meta.model specifies a default value
"""
serializer_info = super().get_serializer_info(serializer)
model_class = None
@ -174,7 +174,7 @@ class InvenTreeMetadata(SimpleMetadata):
# Extract extra information if an instance is available
if hasattr(serializer, 'instance'):
instance = serializer.instance
if instance is None and model_class is not None:
# Attempt to find the instance based on kwargs lookup
kwargs = getattr(self.view, 'kwargs', None)
@ -240,7 +240,7 @@ class InvenTreeMetadata(SimpleMetadata):
# Introspect writable related fields
if field_info['type'] == 'field' and not field_info['read_only']:
# If the field is a PrimaryKeyRelatedField, we can extract the model from the queryset
if isinstance(field, serializers.PrimaryKeyRelatedField):
model = field.queryset.model

View File

@ -66,7 +66,7 @@ class InvenTreeMoneySerializer(MoneyField):
if currency and amount is not None and not isinstance(amount, MONEY_CLASSES) and amount is not empty:
return Money(amount, currency)
return amount

View File

@ -26,6 +26,7 @@ import moneyed
import yaml
from django.utils.translation import gettext_lazy as _
from django.contrib.messages import constants as messages
import django.conf.locale
def _is_true(x):
@ -306,7 +307,7 @@ MIDDLEWARE = CONFIG.get('middleware', [
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'InvenTree.middleware.AuthRequiredMiddleware'
'InvenTree.middleware.AuthRequiredMiddleware',
])
# Error reporting middleware
@ -664,6 +665,7 @@ LANGUAGES = [
('el', _('Greek')),
('en', _('English')),
('es', _('Spanish')),
('es-mx', _('Spanish (Mexican)')),
('fr', _('French')),
('he', _('Hebrew')),
('it', _('Italian')),
@ -681,6 +683,25 @@ LANGUAGES = [
('zh-cn', _('Chinese')),
]
# Testing interface translations
if get_setting('TEST_TRANSLATIONS', False):
# Set default language
LANGUAGE_CODE = 'xx'
# Add to language catalog
LANGUAGES.append(('xx', 'Test'))
# Add custom languages not provided by Django
EXTRA_LANG_INFO = {
'xx': {
'code': 'xx',
'name': 'Test',
'name_local': 'Test'
},
}
LANG_INFO = dict(django.conf.locale.LANG_INFO, **EXTRA_LANG_INFO)
django.conf.locale.LANG_INFO = LANG_INFO
# Currencies available for use
CURRENCIES = CONFIG.get(
'currencies',

View File

@ -208,7 +208,7 @@ function inventreeDocReady() {
});
// Callback for "admin view" button
$('#admin-button').click(function() {
$('#admin-button, .admin-button').click(function() {
var url = $(this).attr('url');
location.href = url;

View File

@ -106,7 +106,7 @@ def offload_task(taskname, *args, force_sync=False, **kwargs):
except NameError:
logger.warning(f"WARNING: '{taskname}' not started - No function named '{func}'")
return
# Workers are not running: run it as synchronous task
_func(*args, **kwargs)

View File

@ -19,7 +19,7 @@ from base64 import b64encode
class HTMLAPITests(TestCase):
"""
Test that we can access the REST API endpoints via the HTML interface.
History: Discovered on 2021-06-28 a bug in InvenTreeModelSerializer,
which raised an AssertionError when using the HTML API interface,
while the regular JSON interface continued to work as expected.
@ -280,7 +280,7 @@ class APITests(InvenTreeAPITestCase):
"""
Tests for detail API endpoint actions
"""
self.basicAuth()
url = reverse('api-part-detail', kwargs={'pk': 1})

View File

@ -77,7 +77,7 @@ apipatterns = [
settings_urls = [
url(r'^i18n/?', include('django.conf.urls.i18n')),
url(r'^appearance/?', AppearanceSelectView.as_view(), name='settings-appearance'),
url(r'^currencies-refresh/', CurrencyRefreshView.as_view(), name='settings-currencies-refresh'),

View File

@ -123,10 +123,10 @@ def isInvenTreeDevelopmentVersion():
def inventreeDocsVersion():
"""
Return the version string matching the latest documentation.
Development -> "latest"
Release -> "major.minor.sub" e.g. "0.5.2"
"""
if isInvenTreeDevelopmentVersion():

View File

@ -198,7 +198,7 @@ class BuildUnallocate(generics.CreateAPIView):
queryset = Build.objects.none()
serializer_class = BuildUnallocationSerializer
def get_serializer_context(self):
ctx = super().get_serializer_context()
@ -231,7 +231,7 @@ class BuildComplete(generics.CreateAPIView):
ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
except:
pass
return ctx
@ -296,7 +296,7 @@ class BuildItemList(generics.ListCreateAPIView):
kwargs['location_detail'] = str2bool(params.get('location_detail', False))
except AttributeError:
pass
return self.serializer_class(*args, **kwargs)
def get_queryset(self):

View File

@ -66,7 +66,7 @@ def get_next_build_number():
attempts.add(reference)
else:
break
return reference
@ -94,13 +94,13 @@ class Build(MPTTModel, ReferenceIndexingMixin):
"""
OVERDUE_FILTER = Q(status__in=BuildStatus.ACTIVE_CODES) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date())
@staticmethod
def get_api_url():
return reverse('api-build-list')
def api_instance_filters(self):
return {
'parent': {
'exclude_tree': self.pk,
@ -1178,7 +1178,7 @@ class BuildItem(models.Model):
bom_item = PartModels.BomItem.objects.get(part=self.build.part, sub_part=ancestor)
except PartModels.BomItem.DoesNotExist:
continue
# A matching BOM item has been found!
if idx == 0 or bom_item.allow_variants:
bom_item_valid = True
@ -1234,7 +1234,7 @@ class BuildItem(models.Model):
thumb_url = self.stock_item.part.image.thumbnail.url
except:
pass
if thumb_url is None and self.bom_item and self.bom_item.sub_part:
try:
thumb_url = self.bom_item.sub_part.image.thumbnail.url

View File

@ -309,7 +309,7 @@ class BuildAllocationItemSerializer(serializers.Serializer):
)
def validate_bom_item(self, bom_item):
# TODO: Fix this validation - allow for variants and substitutes!
build = self.context['build']
@ -332,7 +332,7 @@ class BuildAllocationItemSerializer(serializers.Serializer):
if not stock_item.in_stock:
raise ValidationError(_("Item must be in stock"))
return stock_item
quantity = serializers.DecimalField(
@ -398,7 +398,7 @@ class BuildAllocationItemSerializer(serializers.Serializer):
# Output *cannot* be set for un-tracked parts
if output is not None and not bom_item.sub_part.trackable:
raise ValidationError({
'output': _('Build output cannot be specified for allocation of untracked parts')
})
@ -422,14 +422,14 @@ class BuildAllocationSerializer(serializers.Serializer):
"""
Validation
"""
data = super().validate(data)
items = data.get('items', [])
if len(items) == 0:
raise ValidationError(_('Allocation items must be provided'))
return data
def save(self):

View File

@ -2,14 +2,21 @@
{% load static %}
{% load inventree_extras %}
{% include "sidebar_item.html" with label='details' text="Build Order Details" icon="fa-info-circle" %}
{% trans "Build Order Details" as text %}
{% include "sidebar_item.html" with label='details' text=text icon="fa-info-circle" %}
{% if build.active %}
{% include "sidebar_item.html" with label='allocate' text="Allocate Stock" icon="fa-tasks" %}
{% trans "Allocate Stock" as text %}
{% include "sidebar_item.html" with label='allocate' text=text icon="fa-tasks" %}
{% endif %}
{% if not build.is_complete %}
{% include "sidebar_item.html" with label='outputs' text="Pending Items" icon="fa-tools" %}
{% trans "Pending Items" as text %}
{% include "sidebar_item.html" with label='outputs' text=text icon="fa-tools" %}
{% endif %}
{% include "sidebar_item.html" with label='completed' text="Completed Items" icon="fa-boxes" %}
{% include "sidebar_item.html" with label='children' text="Child Build Orders" icon="fa-sitemap" %}
{% include "sidebar_item.html" with label='attachments' text="Attachments" icon="fa-paperclip" %}
{% include "sidebar_item.html" with label='notes' text="Notes" icon="fa-clipboard" %}
{% trans "Completed Items" as text %}
{% include "sidebar_item.html" with label='completed' text=text icon="fa-boxes" %}
{% trans "Child Build Orders" as text %}
{% include "sidebar_item.html" with label='children' text=text icon="fa-sitemap" %}
{% trans "Attachments" as text %}
{% include "sidebar_item.html" with label='attachments' text=text icon="fa-paperclip" %}
{% trans "Notes" as text %}
{% include "sidebar_item.html" with label='notes' text=text icon="fa-clipboard" %}

View File

@ -73,7 +73,7 @@ class GlobalSettingsDetail(generics.RetrieveUpdateAPIView):
permission_classes = [
GlobalSettingsPermissions,
]
class UserSettingsList(SettingsList):
"""
@ -124,7 +124,7 @@ class UserSettingsDetail(generics.RetrieveUpdateAPIView):
queryset = common.models.InvenTreeUserSetting.objects.all()
serializer_class = common.serializers.UserSettingsSerializer
permission_classes = [
UserSettingsPermissions,
]

View File

@ -12,7 +12,7 @@ class CommonConfig(AppConfig):
name = 'common'
def ready(self):
self.clear_restart_flag()
def clear_restart_flag(self):
@ -22,7 +22,7 @@ class CommonConfig(AppConfig):
try:
import common.models
if common.models.InvenTreeSetting.get_setting('SERVER_RESTART_REQUIRED'):
logger.info("Clearing SERVER_RESTART_REQUIRED flag")
common.models.InvenTreeSetting.set_setting('SERVER_RESTART_REQUIRED', False, None)

View File

@ -108,7 +108,9 @@ class BaseInvenTreeSetting(models.Model):
for key, value in settings.items():
validator = cls.get_setting_validator(key)
if cls.validator_is_bool(validator):
if cls.is_protected(key):
value = '***'
elif cls.validator_is_bool(validator):
value = InvenTree.helpers.str2bool(value)
elif cls.validator_is_int(validator):
try:
@ -485,7 +487,7 @@ class BaseInvenTreeSetting(models.Model):
elif self.is_int():
return 'integer'
else:
return 'string'
@ -538,6 +540,19 @@ class BaseInvenTreeSetting(models.Model):
return value
@classmethod
def is_protected(cls, key):
"""
Check if the setting value is protected
"""
key = str(key).strip().upper()
if key in cls.GLOBAL_SETTINGS:
return cls.GLOBAL_SETTINGS[key].get('protected', False)
else:
return False
def settings_group_options():
"""build up group tuple for settings based on gour choices"""

View File

@ -45,6 +45,18 @@ class SettingsSerializer(InvenTreeModelSerializer):
return results
def get_value(self, obj):
"""
Make sure protected values are not returned
"""
result = obj.value
# never return protected values
if obj.is_protected:
result = '***'
return result
class GlobalSettingsSerializer(SettingsSerializer):
"""

View File

@ -170,7 +170,7 @@ class ManufacturerPartParameterList(generics.ListCreateAPIView):
queryset = ManufacturerPartParameter.objects.all()
serializer_class = ManufacturerPartParameterSerializer
def get_serializer(self, *args, **kwargs):
# Do we wish to include any extra detail?

View File

@ -477,7 +477,7 @@ class SupplierPart(models.Model):
return reverse('supplier-part-detail', kwargs={'pk': self.id})
def api_instance_filters(self):
return {
'manufacturer_part': {
'part': self.part.pk

View File

@ -187,7 +187,7 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
part_detail = kwargs.pop('part_detail', True)
supplier_detail = kwargs.pop('supplier_detail', True)
manufacturer_detail = kwargs.pop('manufacturer_detail', True)
prettify = kwargs.pop('pretty', False)
super(SupplierPartSerializer, self).__init__(*args, **kwargs)

View File

@ -2,5 +2,7 @@
{% load static %}
{% load inventree_extras %}
{% include "sidebar_item.html" with label='parameters' text="Parameters" icon="fa-th-list" %}
{% include "sidebar_item.html" with label='supplier-parts' text="Supplier Parts" icon="fa-building" %}
{% trans "Parameters" as text %}
{% include "sidebar_item.html" with label='parameters' text=text icon="fa-th-list" %}
{% trans "Supplier Parts" as text %}
{% include "sidebar_item.html" with label='supplier-parts' text=text icon="fa-building" %}

View File

@ -3,17 +3,24 @@
{% load inventree_extras %}
{% if company.is_manufacturer %}
{% include "sidebar_item.html" with label='manufacturer-parts' text="Manufactured Parts" icon="fa-industry" %}
{% trans "Manufactured Parts" as text %}
{% include "sidebar_item.html" with label='manufacturer-parts' text=text icon="fa-industry" %}
{% endif %}
{% if company.is_supplier %}
{% include "sidebar_item.html" with label='supplier-parts' text="Supplied Parts" icon="fa-building" %}
{% include "sidebar_item.html" with label='purchase-orders' text="Purchase Orders" icon="fa-shopping-cart" %}
{% trans "Supplied Parts" as text %}
{% include "sidebar_item.html" with label='supplier-parts' text=text icon="fa-building" %}
{% trans "Purchase Orders" as text %}
{% include "sidebar_item.html" with label='purchase-orders' text=text icon="fa-shopping-cart" %}
{% endif %}
{% if company.is_manufacturer or company.is_supplier %}
{% include "sidebar_item.html" with label='company-stock' text="Supplied Stock Items" icon="fa-boxes" %}
{% trans "Supplied Stock Items" as text %}
{% include "sidebar_item.html" with label='company-stock' text=text icon="fa-boxes" %}
{% endif %}
{% if company.is_customer %}
{% include "sidebar_item.html" with label='sales-orders' text="Sales Orders" icon="fa-truck" %}
{% include "sidebar_item.html" with label='assigned-stock' text="Assigned Stock Items" icon="fa-sign-out-alt" %}
{% trans "Sales Orders" as text %}
{% include "sidebar_item.html" with label='sales-orders' text=text icon="fa-truck" %}
{% trans "Assigned Stock Items" as text %}
{% include "sidebar_item.html" with label='assigned-stock' text=text icon="fa-sign-out-alt" %}
{% endif %}
{% include "sidebar_item.html" with label='company-notes' text="Notes" icon="fa-clipboard" %}
{% trans "Notes" as text %}
{% include "sidebar_item.html" with label='company-notes' text=text icon="fa-clipboard" %}

View File

@ -2,6 +2,9 @@
{% load static %}
{% load inventree_extras %}
{% include "sidebar_item.html" with label='stock' text="Stock Items" icon="fa-boxes" %}
{% include "sidebar_item.html" with label='purchase-orders' text="Purchase Orders" icon="fa-shopping-cart" %}
{% include "sidebar_item.html" with label='pricing' text="Supplier Part Pricing" icon="fa-dollar-sign" %}
{% trans "Stock Items" as text %}
{% include "sidebar_item.html" with label='stock' text=text icon="fa-boxes" %}
{% trans "Purchase Orders" as text %}
{% include "sidebar_item.html" with label='purchase-orders' text=text icon="fa-shopping-cart" %}
{% trans "Supplier Part Pricing" as text %}
{% include "sidebar_item.html" with label='pricing' text=text icon="fa-dollar-sign" %}

View File

@ -202,7 +202,7 @@ class ManufacturerTest(InvenTreeAPITestCase):
data = {
'MPN': 'MPN-TEST-123',
}
response = self.client.patch(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)

View File

@ -29,7 +29,7 @@ company_urls = [
]
manufacturer_part_urls = [
url(r'^(?P<pk>\d+)/', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part.html'), name='manufacturer-part-detail'),
]

View File

@ -399,7 +399,7 @@ class PartLabelMixin:
if key in params:
parts = params.getlist(key, [])
break
valid_ids = []
for part in parts:

View File

@ -186,7 +186,7 @@ class LabelTemplate(models.Model):
"""
template_string = Template(self.filename_pattern)
ctx = self.context(request)
context = Context(ctx)

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -42,7 +42,7 @@ class POList(generics.ListCreateAPIView):
"""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
item = serializer.save()
item.created_by = request.user
item.save()
@ -397,7 +397,7 @@ class SOList(generics.ListCreateAPIView):
"""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
item = serializer.save()
item.created_by = request.user
item.save()

View File

@ -743,7 +743,7 @@ class PurchaseOrderLineItem(OrderLineItem):
def get_base_part(self):
"""
Return the base part.Part object for the line item
Note: Returns None if the SupplierPart is not set!
"""
if self.part is None:

View File

@ -551,10 +551,10 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True)
quantity = InvenTreeDecimalField()
allocated = serializers.FloatField(source='allocated_quantity', read_only=True)
fulfilled = serializers.FloatField(source='fulfilled_quantity', read_only=True)
sale_price = InvenTreeMoneySerializer(
allow_null=True
)

View File

@ -5,7 +5,8 @@
{% block sidebar %}
{% url "po-detail" order.id as url %}
{% include "sidebar_item.html" with url=url text="Return to Orders" icon="fa-undo" %}
{% trans "Return to Orders" as text %}
{% include "sidebar_item.html" with url=url text=text icon="fa-undo" %}
{% endblock %}
{% block page_content %}

View File

@ -2,7 +2,11 @@
{% load static %}
{% load inventree_extras %}
{% include "sidebar_item.html" with label='order-items' text="Line Items" icon="fa-list-ol" %}
{% include "sidebar_item.html" with label='received-items' text="Received Stock" icon="fa-sign-in-alt" %}
{% include "sidebar_item.html" with label='order-attachments' text="Attachments" icon="fa-paperclip" %}
{% include "sidebar_item.html" with label='order-notes' text="Notes" icon="fa-clipboard" %}
{% trans "Line Items" as text %}
{% include "sidebar_item.html" with label='order-items' text=text icon="fa-list-ol" %}
{% trans "Received Stock" as text %}
{% include "sidebar_item.html" with label='received-items' text=text icon="fa-sign-in-alt" %}
{% trans "Attachments" as text %}
{% include "sidebar_item.html" with label='order-attachments' text=text icon="fa-paperclip" %}
{% trans "Notes" as text %}
{% include "sidebar_item.html" with label='order-notes' text=text icon="fa-clipboard" %}

View File

@ -2,8 +2,13 @@
{% load static %}
{% load inventree_extras %}
{% include "sidebar_item.html" with label='order-items' text="Line Items" icon="fa-list-ol" %}
{% include "sidebar_item.html" with label='order-shipments' text="Shipments" icon="fa-truck" %}
{% include "sidebar_item.html" with label='order-builds' text="Build Orders" icon="fa-tools" %}
{% include "sidebar_item.html" with label='order-attachments' text="Attachments" icon="fa-paperclip" %}
{% include "sidebar_item.html" with label='order-notes' text="Notes" icon="fa-clipboard" %}
{% trans "Line Items" as text %}
{% include "sidebar_item.html" with label='order-items' text=text icon="fa-list-ol" %}
{% trans "Shipments" as text %}
{% include "sidebar_item.html" with label='order-shipments' text=text icon="fa-truck" %}
{% trans "Build Orders" as text %}
{% include "sidebar_item.html" with label='order-builds' text=text icon="fa-tools" %}
{% trans "Attachments" as text %}
{% include "sidebar_item.html" with label='order-attachments' text=text icon="fa-paperclip" %}
{% trans "Notes" as text %}
{% include "sidebar_item.html" with label='order-notes' text=text icon="fa-clipboard" %}

View File

@ -229,7 +229,7 @@ class PurchaseOrderReceiveTest(OrderTest):
def setUp(self):
super().setUp()
self.assignRole('purchase_order.add')
self.url = reverse('api-po-receive', kwargs={'pk': 1})

View File

@ -406,7 +406,7 @@ class PurchaseOrderUpload(FileManagementFormView):
def done(self, form_list, **kwargs):
""" Once all the data is in, process it to add PurchaseOrderLineItem instances to the order """
order = self.get_order()
items = self.get_clean_items()
@ -432,7 +432,7 @@ class PurchaseOrderUpload(FileManagementFormView):
except IntegrityError:
# PurchaseOrderLineItem already exists
pass
return HttpResponseRedirect(reverse('po-detail', kwargs={'pk': self.kwargs['pk']}))
@ -449,7 +449,7 @@ class SalesOrderExport(AjaxView):
role_required = 'sales_order.view'
def get(self, request, *args, **kwargs):
order = get_object_or_404(SalesOrder, pk=self.kwargs.get('pk', None))
export_format = request.GET.get('format', 'csv')

View File

@ -205,7 +205,7 @@ class BomItemResource(ModelResource):
# If we are not generating an "import" template,
# just return the complete list of fields
if not self.is_importing:
if not getattr(self, 'is_importing', False):
return fields
# Otherwise, remove some fields we are not interested in

View File

@ -26,7 +26,7 @@ from djmoney.contrib.exchange.exceptions import MissingRate
from decimal import Decimal, InvalidOperation
from .models import Part, PartCategory
from .models import Part, PartCategory, PartRelated
from .models import BomItem, BomItemSubstitute
from .models import PartParameter, PartParameterTemplate
from .models import PartAttachment, PartTestTemplate
@ -169,7 +169,7 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView):
"""
API endpoint for detail view of a single PartCategory object
"""
serializer_class = part_serializers.CategorySerializer
queryset = PartCategory.objects.all()
@ -222,7 +222,7 @@ class CategoryParameterList(generics.ListAPIView):
if category is not None:
try:
category = PartCategory.objects.get(pk=category)
fetch_parent = str2bool(params.get('fetch_parent', True))
@ -734,7 +734,7 @@ class PartList(generics.ListCreateAPIView):
raise ValidationError({
'initial_stock_quantity': [_('Must be a valid quantity')],
})
initial_stock_location = request.data.get('initial_stock_location', None)
try:
@ -850,7 +850,7 @@ class PartList(generics.ListCreateAPIView):
id_values.append(val)
except ValueError:
pass
queryset = queryset.exclude(pk__in=id_values)
# Exclude part variant tree?
@ -901,6 +901,40 @@ class PartList(generics.ListCreateAPIView):
queryset = queryset.filter(pk__in=pks)
# Filter by 'related' parts?
related = params.get('related', None)
exclude_related = params.get('exclude_related', None)
if related is not None or exclude_related is not None:
try:
pk = related if related is not None else exclude_related
pk = int(pk)
related_part = Part.objects.get(pk=pk)
part_ids = set()
# Return any relationship which points to the part in question
relation_filter = Q(part_1=related_part) | Q(part_2=related_part)
for relation in PartRelated.objects.filter(relation_filter):
if relation.part_1.pk != pk:
part_ids.add(relation.part_1.pk)
if relation.part_2.pk != pk:
part_ids.add(relation.part_2.pk)
if related is not None:
# Only return related results
queryset = queryset.filter(pk__in=[pk for pk in part_ids])
elif exclude_related is not None:
# Exclude related results
queryset = queryset.exclude(pk__in=[pk for pk in part_ids])
except (ValueError, Part.DoesNotExist):
pass
# Filter by 'starred' parts?
starred = params.get('starred', None)
@ -1017,6 +1051,44 @@ class PartList(generics.ListCreateAPIView):
]
class PartRelatedList(generics.ListCreateAPIView):
"""
API endpoint for accessing a list of PartRelated objects
"""
queryset = PartRelated.objects.all()
serializer_class = part_serializers.PartRelationSerializer
def filter_queryset(self, queryset):
queryset = super().filter_queryset(queryset)
params = self.request.query_params
# Add a filter for "part" - we can filter either part_1 or part_2
part = params.get('part', None)
if part is not None:
try:
part = Part.objects.get(pk=part)
queryset = queryset.filter(Q(part_1=part) | Q(part_2=part))
except (ValueError, Part.DoesNotExist):
pass
return queryset
class PartRelatedDetail(generics.RetrieveUpdateDestroyAPIView):
"""
API endpoint for accessing detail view of a PartRelated object
"""
queryset = PartRelated.objects.all()
serializer_class = part_serializers.PartRelationSerializer
class PartParameterTemplateList(generics.ListCreateAPIView):
""" API endpoint for accessing a list of PartParameterTemplate objects.
@ -1081,24 +1153,6 @@ class BomFilter(rest_filters.FilterSet):
inherited = rest_filters.BooleanFilter(label='BOM line is inherited')
allow_variants = rest_filters.BooleanFilter(label='Variants are allowed')
validated = rest_filters.BooleanFilter(label='BOM line has been validated', method='filter_validated')
def filter_validated(self, queryset, name, value):
# Work out which lines have actually been validated
pks = []
for bom_item in queryset.all():
if bom_item.is_line_valid():
pks.append(bom_item.pk)
if str2bool(value):
queryset = queryset.filter(pk__in=pks)
else:
queryset = queryset.exclude(pk__in=pks)
return queryset
# Filters for linked 'part'
part_active = rest_filters.BooleanFilter(label='Master part is active', field_name='part__active')
part_trackable = rest_filters.BooleanFilter(label='Master part is trackable', field_name='part__trackable')
@ -1107,6 +1161,30 @@ class BomFilter(rest_filters.FilterSet):
sub_part_trackable = rest_filters.BooleanFilter(label='Sub part is trackable', field_name='sub_part__trackable')
sub_part_assembly = rest_filters.BooleanFilter(label='Sub part is an assembly', field_name='sub_part__assembly')
validated = rest_filters.BooleanFilter(label='BOM line has been validated', method='filter_validated')
def filter_validated(self, queryset, name, value):
# Work out which lines have actually been validated
pks = []
value = str2bool(value)
# Shortcut for quicker filtering - BomItem with empty 'checksum' values are not validated
if value:
queryset = queryset.exclude(checksum=None).exclude(checksum='')
for bom_item in queryset.all():
if bom_item.is_line_valid:
pks.append(bom_item.pk)
if value:
queryset = queryset.filter(pk__in=pks)
else:
queryset = queryset.exclude(pk__in=pks)
return queryset
class BomList(generics.ListCreateAPIView):
"""
@ -1257,7 +1335,7 @@ class BomList(generics.ListCreateAPIView):
queryset = self.annotate_pricing(queryset)
return queryset
def include_pricing(self):
"""
Determine if pricing information should be included in the response
@ -1291,7 +1369,7 @@ class BomList(generics.ListCreateAPIView):
# Get default currency from settings
default_currency = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY')
if price:
if currency and default_currency:
try:
@ -1381,7 +1459,7 @@ class BomItemSubstituteList(generics.ListCreateAPIView):
serializer_class = part_serializers.BomItemSubstituteSerializer
queryset = BomItemSubstitute.objects.all()
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
@ -1435,6 +1513,12 @@ part_api_urls = [
url(r'^.*$', PartInternalPriceList.as_view(), name='api-part-internal-price-list'),
])),
# Base URL for PartRelated API endpoints
url(r'^related/', include([
url(r'^(?P<pk>\d+)/', PartRelatedDetail.as_view(), name='api-part-related-detail'),
url(r'^.*$', PartRelatedList.as_view(), name='api-part-related-list'),
])),
# Base URL for PartParameter API endpoints
url(r'^parameter/', include([
url(r'^template/$', PartParameterTemplateList.as_view(), name='api-part-parameter-template-list'),

View File

@ -59,7 +59,7 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
uids = []
def add_items(items, level, cascade):
def add_items(items, level, cascade=True):
# Add items at a given layer
for item in items:
@ -172,7 +172,7 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
# Filter manufacturer parts
manufacturer_parts = ManufacturerPart.objects.filter(part__pk=b_part.pk).prefetch_related('supplier_parts')
for mp_idx, mp_part in enumerate(manufacturer_parts):
# Extract the "name" field of the Manufacturer (Company)
@ -190,7 +190,7 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
# Generate a column name for this manufacturer
k_man = f'{_("Manufacturer")}_{mp_idx}'
k_mpn = f'{_("MPN")}_{mp_idx}'
try:
manufacturer_cols[k_man].update({bom_idx: manufacturer_name})
manufacturer_cols[k_mpn].update({bom_idx: manufacturer_mpn})
@ -200,7 +200,7 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
# We wish to include supplier data for this manufacturer part
if supplier_data:
for sp_idx, sp_part in enumerate(mp_part.supplier_parts.all()):
supplier_parts_used.add(sp_part)

View File

@ -17,7 +17,7 @@ from InvenTree.fields import RoundingDecimalFormField
import common.models
from common.forms import MatchItemForm
from .models import Part, PartCategory, PartRelated
from .models import Part, PartCategory
from .models import PartParameterTemplate
from .models import PartCategoryParameterTemplate
from .models import PartSellPriceBreak, PartInternalPriceBreak
@ -157,20 +157,6 @@ class BomMatchItemForm(MatchItemForm):
return super().get_special_field(col_guess, row, file_manager)
class CreatePartRelatedForm(HelperForm):
""" Form for creating a PartRelated object """
class Meta:
model = PartRelated
fields = [
'part_1',
'part_2',
]
labels = {
'part_2': _('Related Part'),
}
class SetPartCategoryForm(forms.Form):
""" Form for setting the category of multiple Part objects """

View File

@ -1587,7 +1587,7 @@ class Part(MPTTModel):
# Exclude any parts that this part is used *in* (to prevent recursive BOMs)
used_in = self.get_used_in().all()
parts = parts.exclude(id__in=[item.part.id for item in used_in])
parts = parts.exclude(id__in=[part.id for part in used_in])
return parts
@ -2118,7 +2118,7 @@ class Part(MPTTModel):
"""
Returns True if the total stock for this part is less than the minimum stock level
"""
return self.get_stock_count() < self.minimum_stock
@ -2155,7 +2155,7 @@ class PartSellPriceBreak(common.models.PriceBreak):
"""
Represents a price break for selling this part
"""
@staticmethod
def get_api_url():
return reverse('api-part-sale-price-list')

View File

@ -25,7 +25,7 @@ from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
from stock.models import StockItem
from .models import (BomItem, BomItemSubstitute,
Part, PartAttachment, PartCategory,
Part, PartAttachment, PartCategory, PartRelated,
PartParameter, PartParameterTemplate, PartSellPriceBreak,
PartStar, PartTestTemplate, PartCategoryParameterTemplate,
PartInternalPriceBreak)
@ -388,6 +388,25 @@ class PartSerializer(InvenTreeModelSerializer):
]
class PartRelationSerializer(InvenTreeModelSerializer):
"""
Serializer for a PartRelated model
"""
part_1_detail = PartSerializer(source='part_1', read_only=True, many=False)
part_2_detail = PartSerializer(source='part_2', read_only=True, many=False)
class Meta:
model = PartRelated
fields = [
'pk',
'part_1',
'part_1_detail',
'part_2',
'part_2_detail',
]
class PartStarSerializer(InvenTreeModelSerializer):
""" Serializer for a PartStar object """
@ -446,9 +465,9 @@ class BomItemSerializer(InvenTreeModelSerializer):
purchase_price_min = MoneyField(max_digits=10, decimal_places=6, read_only=True)
purchase_price_max = MoneyField(max_digits=10, decimal_places=6, read_only=True)
purchase_price_avg = serializers.SerializerMethodField()
purchase_price_range = serializers.SerializerMethodField()
def __init__(self, *args, **kwargs):
@ -520,7 +539,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
def get_purchase_price_avg(self, obj):
""" Return purchase price average """
try:
purchase_price_avg = obj.purchase_price_avg
except AttributeError:

View File

@ -62,7 +62,7 @@ def notify_low_stock(part: part.models.Part):
def notify_low_stock_if_required(part: part.models.Part):
"""
Check if the stock quantity has fallen below the minimum threshold of part.
If true, notify the users who have subscribed to the part
"""

View File

@ -5,7 +5,8 @@
{% block sidebar %}
{% url "part-detail" part.id as url %}
{% include "sidebar_link.html" with url=url text="Return to BOM" icon="fa-undo" %}
{% trans "Return to BOM" as text %}
{% include "sidebar_link.html" with url=url text=text icon="fa-undo" %}
{% endblock %}
{% block heading %}

View File

@ -4,12 +4,16 @@
{% settings_value 'PART_SHOW_IMPORT' as show_import %}
{% include "sidebar_item.html" with label="subcategories" text="Subcategories" icon="fa-sitemap" %}
{% include "sidebar_item.html" with label="parts" text="Parts" icon="fa-shapes" %}
{% trans "Subcategories" as text %}
{% include "sidebar_item.html" with label="subcategories" text=text icon="fa-sitemap" %}
{% trans "Parts" as text %}
{% include "sidebar_item.html" with label="parts" text=text icon="fa-shapes" %}
{% if show_import and user.is_staff and roles.part.add %}
{% url "part-import" as url %}
{% include "sidebar_link.html" with url=url text="Import Parts" icon="fa-file-upload" %}
{% trans "Import Parts" as text %}
{% include "sidebar_link.html" with url=url text=text icon="fa-file-upload" %}
{% endif %}
{% if category %}
{% include "sidebar_item.html" with label="parameters" text="Parameters" icon="fa-tasks" %}
{% trans "Parameters" as text %}
{% include "sidebar_item.html" with label="parameters" text=text icon="fa-tasks" %}
{% endif %}

View File

@ -329,34 +329,8 @@
{% include "filter_list.html" with id="related" %}
</div>
</div>
<table id='table-related-part' class='table table-condensed table-striped' data-toolbar='#related-button-toolbar'>
<thead>
<tr>
<th data-field='part' data-serachable='true'>{% trans "Part" %}</th>
</tr>
</thead>
<tbody>
{% for item in part.get_related_parts %}
{% with part_related=item.0 part=item.1 %}
<tr>
<td>
<a class='hover-icon'>
<img class='hover-img-thumb' src='{{ part.get_thumbnail_url }}'>
<img class='hover-img-large' src='{{ part.get_thumbnail_url }}'>
</a>
<a href='/part/{{ part.id }}/'>{{ part }}</a>
<div class='btn-group' style='float: right;'>
{% if roles.part.change %}
<button title='{% trans "Delete" %}' class='btn btn-outline-secondary delete-related-part' url="{% url 'part-related-delete' part_related.id %}" type='button'><span class='fas fa-trash-alt icon-red'/></button>
{% endif %}
</div>
</td>
</tr>
{% endwith %}
{% endfor %}
</tbody>
</table>
<table id='related-parts-table' class='table table-striped table-condensed' data-toolbar='#related-button-toolbar'></table>
</div>
</div>
@ -771,15 +745,32 @@
// Load the "related parts" tab
onPanelLoad("related-parts", function() {
$('#table-related-part').inventreeTable({
});
loadRelatedPartsTable(
"#related-parts-table",
{{ part.pk }}
);
$("#add-related-part").click(function() {
launchModalForm("{% url 'part-related-create' %}", {
data: {
part: {{ part.id }},
constructForm('{% url "api-part-related-list" %}', {
method: 'POST',
fields: {
part_1: {
hidden: true,
value: {{ part.pk }},
},
part_2: {
label: '{% trans "Related Part" %}',
filters: {
exclude_related: {{ part.pk }},
}
}
},
reload: true,
title: '{% trans "Add Related Part" %}',
onSuccess: function() {
$('#related-parts-table').bootstrapTable('refresh');
}
});
});

View File

@ -5,7 +5,8 @@
{% block sidebar %}
{% url 'part-index' as url %}
{% include "sidebar_link.html" with url=url text="Return to Parts" icon="fa-undo" %}
{% trans "Return to Parts" as text %}
{% include "sidebar_link.html" with url=url text=text icon="fa-undo" %}
{% endblock %}
{% block content %}

View File

@ -5,34 +5,49 @@
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
{% settings_value 'PART_SHOW_RELATED' as show_related %}
{% include "sidebar_item.html" with label="part-details" text="Details" icon="fa-shapes" %}
{% include "sidebar_item.html" with label="part-parameters" text="Parameters" icon="fa-th-list" %}
{% trans "Details" as text %}
{% include "sidebar_item.html" with label="part-details" text=text icon="fa-shapes" %}
{% trans "Parameters" as text %}
{% include "sidebar_item.html" with label="part-parameters" text=text icon="fa-th-list" %}
{% if part.is_template %}
{% include "sidebar_item.html" with label="variants" text="Variants" icon="fa-shapes" %}
{% trans "Variants" as text %}
{% include "sidebar_item.html" with label="variants" text=text icon="fa-shapes" %}
{% endif %}
{% include "sidebar_item.html" with label="part-stock" text="Stock" icon="fa-boxes" %}
{% trans "Stock" as text %}
{% include "sidebar_item.html" with label="part-stock" text=text icon="fa-boxes" %}
{% if part.assembly %}
{% include "sidebar_item.html" with label="bom" text="Bill of Materials" icon="fa-list" %}
{% trans "Bill of Materials" as text %}
{% include "sidebar_item.html" with label="bom" text=text icon="fa-list" %}
{% if roles.build.view %}
{% include "sidebar_item.html" with label="build-orders" text="Build Orders" icon="fa-tools" %}
{% trans "Build Orders" as text %}
{% include "sidebar_item.html" with label="build-orders" text=text icon="fa-tools" %}
{% endif %}
{% endif %}
{% if part.component %}
{% include "sidebar_item.html" with label="used-in" text="Used In" icon="fa-layer-group" %}
{% trans "Used In" as text %}
{% include "sidebar_item.html" with label="used-in" text=text icon="fa-layer-group" %}
{% endif %}
{% include "sidebar_item.html" with label="pricing" text="Pricing" icon="fa-dollar-sign" %}
{% trans "Pricing" as text %}
{% include "sidebar_item.html" with label="pricing" text=text icon="fa-dollar-sign" %}
{% if part.purchaseable and roles.purchase_order.view %}
{% include "sidebar_item.html" with label="suppliers" text="Suppliers" icon="fa-building" %}
{% include "sidebar_item.html" with label="purchase-orders" text="Purchase Orders" icon="fa-shopping-cart" %}
{% trans "Suppliers" as text %}
{% include "sidebar_item.html" with label="suppliers" text=text icon="fa-building" %}
{% trans "Purchase Orders" as text %}
{% include "sidebar_item.html" with label="purchase-orders" text=text icon="fa-shopping-cart" %}
{% endif %}
{% if part.salable and roles.sales_order.view %}
{% include "sidebar_item.html" with label="sales-orders" text="Sales Orders" icon="fa-truck" %}
{% trans "Sales Orders" as text %}
{% include "sidebar_item.html" with label="sales-orders" text=text icon="fa-truck" %}
{% endif %}
{% if part.trackable %}
{% include "sidebar_item.html" with label="test-templates" text="Test Templates" icon="fa-vial" %}
{% trans "Test Templates" as text %}
{% include "sidebar_item.html" with label="test-templates" text=text icon="fa-vial" %}
{% endif %}
{% if show_related %}
{% include "sidebar_item.html" with label="related-parts" text="Related Parts" icon="fa-random" %}
{% trans "Related Parts" as text %}
{% include "sidebar_item.html" with label="related-parts" text=text icon="fa-random" %}
{% endif %}
{% include "sidebar_item.html" with label="part-attachments" text="Attachments" icon="fa-paperclip" %}
{% include "sidebar_item.html" with label="part-notes" text="Notes" icon="fa-clipboard" %}
{% trans "Attachments" as text %}
{% include "sidebar_item.html" with label="part-attachments" text=text icon="fa-paperclip" %}
{% trans "Notes" as text %}
{% include "sidebar_item.html" with label="part-notes" text=text icon="fa-clipboard" %}

View File

@ -236,7 +236,7 @@ def settings_value(key, *args, **kwargs):
if 'user' in kwargs:
return InvenTreeUserSetting.get_setting(key, user=kwargs['user'])
return InvenTreeSetting.get_setting(key)
@ -384,7 +384,7 @@ def keyvalue(dict, key):
def call_method(obj, method_name, *args):
"""
enables calling model methods / functions from templates with arguments
usage:
{% call_method model_object 'fnc_name' argument1 %}
"""

View File

@ -542,7 +542,7 @@ class PartAPITest(InvenTreeAPITestCase):
# Check that there is a new manufacturer part *and* a new supplier part
self.assertEqual(new_part.supplier_parts.count(), 1)
self.assertEqual(new_part.manufacturer_parts.count(), 1)
def test_strange_chars(self):
"""
Test that non-standard ASCII chars are accepted
@ -911,7 +911,7 @@ class BomItemTest(InvenTreeAPITestCase):
# How many BOM items currently exist in the database?
n = BomItem.objects.count()
url = reverse('api-bom-list')
response = self.get(url, expected_code=200)
self.assertEqual(len(response.data), n)
@ -925,7 +925,46 @@ class BomItemTest(InvenTreeAPITestCase):
expected_code=200
)
print("results:", len(response.data))
# Filter by "validated"
response = self.get(
url,
data={
'validated': True,
},
expected_code=200,
)
# Should be zero validated results
self.assertEqual(len(response.data), 0)
# Now filter by "not validated"
response = self.get(
url,
data={
'validated': False,
},
expected_code=200
)
# There should be at least one non-validated item
self.assertTrue(len(response.data) > 0)
# Now, let's validate an item
bom_item = BomItem.objects.first()
bom_item.validate_hash()
response = self.get(
url,
data={
'validated': True,
},
expected_code=200
)
# Check that the expected response is returned
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]['pk'], bom_item.pk)
def test_get_bom_detail(self):
"""
@ -962,7 +1001,7 @@ class BomItemTest(InvenTreeAPITestCase):
}
self.post(url, data, expected_code=201)
# Now try to create a BomItem which references itself
data['part'] = 100
data['sub_part'] = 100
@ -1003,7 +1042,7 @@ class BomItemTest(InvenTreeAPITestCase):
# Now we will create some variant parts and stock
for ii in range(5):
# Create a variant part!
variant = Part.objects.create(
name=f"Variant_{ii}",

View File

@ -153,7 +153,7 @@ class BomItemTest(TestCase):
subs = []
for ii in range(5):
# Create a new part
sub_part = Part.objects.create(
name=f"Orphan {ii}",
@ -181,7 +181,7 @@ class BomItemTest(TestCase):
# There should be now 5 substitute parts available
self.assertEqual(bom_item.substitutes.count(), 5)
# Try to create a substitute which points to the same sub-part (should fail)
with self.assertRaises(django_exceptions.ValidationError):
BomItemSubstitute.objects.create(

View File

@ -370,7 +370,7 @@ class PartSubscriptionTests(TestCase):
# electronics / IC / MCU
self.category = PartCategory.objects.get(pk=4)
self.part = Part.objects.create(
category=self.category,
name='STM32F103',
@ -382,7 +382,7 @@ class PartSubscriptionTests(TestCase):
"""
Test basic subscription against a part
"""
# First check that the user is *not* subscribed to the part
self.assertFalse(self.part.is_starred_by(self.user))
@ -450,7 +450,7 @@ class PartSubscriptionTests(TestCase):
"""
Check that a parent category can be subscribed to
"""
# Top-level "electronics" category
cat = PartCategory.objects.get(pk=1)

View File

@ -5,7 +5,7 @@ from django.urls import reverse
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from .models import Part, PartRelated
from .models import Part
class PartViewTestCase(TestCase):
@ -145,36 +145,6 @@ class PartDetailTest(PartViewTestCase):
self.assertIn('streaming_content', dir(response))
class PartRelatedTests(PartViewTestCase):
def test_valid_create(self):
""" test creation of a related part """
# Test GET view
response = self.client.get(reverse('part-related-create'), {'part': 1},
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# Test POST view with valid form data
response = self.client.post(reverse('part-related-create'), {'part_1': 1, 'part_2': 2},
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertContains(response, '"form_valid": true', status_code=200)
# Try to create the same relationship with part_1 and part_2 pks reversed
response = self.client.post(reverse('part-related-create'), {'part_1': 2, 'part_2': 1},
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertContains(response, '"form_valid": false', status_code=200)
# Try to create part related to itself
response = self.client.post(reverse('part-related-create'), {'part_1': 1, 'part_2': 1},
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertContains(response, '"form_valid": false', status_code=200)
# Check final count
n = PartRelated.objects.all().count()
self.assertEqual(n, 1)
class PartQRTest(PartViewTestCase):
""" Tests for the Part QR Code AJAX view """

View File

@ -12,10 +12,6 @@ from django.conf.urls import url, include
from . import views
part_related_urls = [
url(r'^new/?', views.PartRelatedCreate.as_view(), name='part-related-create'),
url(r'^(?P<pk>\d+)/delete/?', views.PartRelatedDelete.as_view(), name='part-related-delete'),
]
sale_price_break_urls = [
url(r'^new/', views.PartSalePriceBreakCreate.as_view(), name='sale-price-break-create'),
@ -40,7 +36,7 @@ part_detail_urls = [
url(r'^bom-export/?', views.BomExport.as_view(), name='bom-export'),
url(r'^bom-download/?', views.BomDownload.as_view(), name='bom-download'),
url(r'^validate-bom/', views.BomValidate.as_view(), name='bom-validate'),
url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'),
url(r'^bom-upload/?', views.BomUpload.as_view(), name='upload-bom'),
@ -96,9 +92,6 @@ part_urls = [
# Part category
url(r'^category/', include(category_urls)),
# Part related
url(r'^related-parts/', include(part_related_urls)),
# Part price breaks
url(r'^sale-price/', include(sale_price_break_urls)),

View File

@ -30,7 +30,7 @@ import io
from rapidfuzz import fuzz
from decimal import Decimal, InvalidOperation
from .models import PartCategory, Part, PartRelated
from .models import PartCategory, Part
from .models import PartParameterTemplate
from .models import PartCategoryParameterTemplate
from .models import BomItem
@ -85,75 +85,6 @@ class PartIndex(InvenTreeRoleMixin, ListView):
return context
class PartRelatedCreate(AjaxCreateView):
""" View for creating a new PartRelated object
- The view only makes sense if a Part object is passed to it
"""
model = PartRelated
form_class = part_forms.CreatePartRelatedForm
ajax_form_title = _("Add Related Part")
ajax_template_name = "modal_form.html"
def get_initial(self):
""" Set parent part as part_1 field """
initials = {}
part_id = self.request.GET.get('part', None)
if part_id:
try:
initials['part_1'] = Part.objects.get(pk=part_id)
except (Part.DoesNotExist, ValueError):
pass
return initials
def get_form(self):
""" Create a form to upload a new PartRelated
- Hide the 'part_1' field (parent part)
- Display parts which are not yet related
"""
form = super(AjaxCreateView, self).get_form()
form.fields['part_1'].widget = HiddenInput()
try:
# Get parent part
parent_part = self.get_initial()['part_1']
# Get existing related parts
related_parts = [related_part[1].pk for related_part in parent_part.get_related_parts()]
# Build updated choice list excluding
# - parts already related to parent part
# - the parent part itself
updated_choices = []
for choice in form.fields["part_2"].choices:
if (choice[0] not in related_parts) and (choice[0] != parent_part.pk):
updated_choices.append(choice)
# Update choices for related part
form.fields['part_2'].choices = updated_choices
except KeyError:
pass
return form
class PartRelatedDelete(AjaxDeleteView):
""" View for deleting a PartRelated object """
model = PartRelated
ajax_form_title = _("Delete Related Part")
context_object_name = "related"
# Explicit role requirement
role_required = 'part.change'
class PartSetCategory(AjaxUpdateView):
""" View for settings the part category for multiple parts at once """
@ -459,7 +390,7 @@ class PartDetail(InvenTreeRoleMixin, DetailView):
part = self.get_object()
ctx = part.get_context_data(self.request)
context.update(**ctx)
# Pricing information
@ -1056,7 +987,7 @@ class BomUpload(InvenTreeRoleMixin, FileManagementFormView):
matches = sorted(matches, key=lambda item: item['match'], reverse=True)
part_options = [m['part'] for m in matches]
# Supply list of part options for each row, sorted by how closely they match the part name
row['item_options'] = part_options
@ -1520,11 +1451,11 @@ class CategoryDetail(InvenTreeRoleMixin, DetailView):
# Prefetch parts parameters
parts_parameters = category.prefetch_parts_parameters(cascade=cascade)
# Get table headers (unique parameters names)
context['headers'] = category.get_unique_parameters(cascade=cascade,
prefetch=parts_parameters)
# Insert part information
context['headers'].insert(0, 'description')
context['headers'].insert(0, 'part')

View File

@ -247,7 +247,7 @@ class ReportTemplateBase(ReportBase):
"""
template_string = Template(self.filename_pattern)
ctx = self.context(request)
context = Context(ctx)

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