Merge branch 'master' into part-import

This commit is contained in:
Matthias Mair 2021-06-26 23:58:41 +02:00 committed by GitHub
commit e760aa172a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
77 changed files with 10989 additions and 8160 deletions

49
.github/workflows/python.yaml vendored Normal file
View File

@ -0,0 +1,49 @@
# Run python library tests whenever code is pushed to master
name: Python Bindings
on:
push:
branches:
- master
pull_request:
branches-ignore:
- l10*
jobs:
python:
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INVENTREE_DB_NAME: './test_db.sqlite'
INVENTREE_DB_ENGINE: 'sqlite3'
INVENTREE_DEBUG: info
INVENTREE_MEDIA_ROOT: ./media
INVENTREE_STATIC_ROOT: ./static
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Install InvenTree
run: |
sudo apt-get update
sudo apt-get install python3-dev python3-pip python3-venv
pip3 install invoke
invoke install
invoke migrate
- name: Download Python Code
run: |
git clone --depth 1 https://github.com/inventree/inventree-python ./inventree-python
- name: Start Server
run: |
invoke import-records -f ./inventree-python/test/test_data.json
invoke server -a 127.0.0.1:8000 &
sleep 60
- name: Run Tests
run: |
cd inventree-python
invoke test

3
.gitignore vendored
View File

@ -35,6 +35,9 @@ local_settings.py
*.backup *.backup
*.old *.old
# Files used for testing
dummy_image.*
# Sphinx files # Sphinx files
docs/_build docs/_build

View File

@ -73,22 +73,50 @@ class InvenTreeAPITestCase(APITestCase):
ruleset.save() ruleset.save()
break break
def get(self, url, data={}, code=200): def get(self, url, data={}, expected_code=200):
""" """
Issue a GET request Issue a GET request
""" """
response = self.client.get(url, data, format='json') response = self.client.get(url, data, format='json')
self.assertEqual(response.status_code, code) if expected_code is not None:
self.assertEqual(response.status_code, expected_code)
return response return response
def post(self, url, data): def post(self, url, data, expected_code=None):
""" """
Issue a POST request Issue a POST request
""" """
response = self.client.post(url, data=data, format='json') response = self.client.post(url, data=data, format='json')
if expected_code is not None:
self.assertEqual(response.status_code, expected_code)
return response
def delete(self, url, expected_code=None):
"""
Issue a DELETE request
"""
response = self.client.delete(url)
if expected_code is not None:
self.assertEqual(response.status_code, expected_code)
return response
def patch(self, url, data, files=None, expected_code=None):
"""
Issue a PATCH request
"""
response = self.client.patch(url, data=data, files=files, format='json')
if expected_code is not None:
self.assertEqual(response.status_code, expected_code)
return response return response

View File

@ -12,7 +12,7 @@ def isInTestMode():
return False return False
def canAppAccessDatabase(): def canAppAccessDatabase(allow_test=False):
""" """
Returns True if the apps.py file can access database records. Returns True if the apps.py file can access database records.
@ -33,11 +33,16 @@ def canAppAccessDatabase():
'createsuperuser', 'createsuperuser',
'wait_for_db', 'wait_for_db',
'prerender', 'prerender',
'rebuild',
'collectstatic', 'collectstatic',
'makemessages', 'makemessages',
'compilemessages', 'compilemessages',
] ]
if not allow_test:
# Override for testing mode?
excluded_commands.append('test')
for cmd in excluded_commands: for cmd in excluded_commands:
if cmd in sys.argv: if cmd in sys.argv:
return False return False

View File

@ -6,12 +6,15 @@ Serializers used in various InvenTree apps
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from rest_framework import serializers
import os import os
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import ValidationError as DjangoValidationError
from rest_framework import serializers
from rest_framework.fields import empty
from rest_framework.exceptions import ValidationError
class UserSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer):
@ -39,18 +42,34 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
but also ensures that the underlying model class data are checked on validation. but also ensures that the underlying model class data are checked on validation.
""" """
def validate(self, data): def run_validation(self, data=empty):
""" Perform serializer validation. """ Perform serializer validation.
In addition to running validators on the serializer fields, In addition to running validators on the serializer fields,
this class ensures that the underlying model is also validated. this class ensures that the underlying model is also validated.
""" """
# Run any native validation checks first (may throw an ValidationError) # Run any native validation checks first (may raise a ValidationError)
data = super(serializers.ModelSerializer, self).validate(data) data = super().run_validation(data)
# Now ensure the underlying model is correct # Now ensure the underlying model is correct
instance = self.Meta.model(**data)
instance.clean() if not hasattr(self, 'instance') or self.instance is None:
# No instance exists (we are creating a new one)
instance = self.Meta.model(**data)
else:
# Instance already exists (we are updating!)
instance = self.instance
# Update instance fields
for attr, value in data.items():
setattr(instance, attr, value)
# Run a 'full_clean' on the model.
# Note that by default, DRF does *not* perform full model validation!
try:
instance.full_clean()
except (ValidationError, DjangoValidationError) as exc:
raise ValidationError(detail=serializers.as_serializer_error(exc))
return data return data
@ -82,3 +101,17 @@ class InvenTreeAttachmentSerializerField(serializers.FileField):
return None return None
return os.path.join(str(settings.MEDIA_URL), str(value)) return os.path.join(str(settings.MEDIA_URL), str(value))
class InvenTreeImageSerializerField(serializers.ImageField):
"""
Custom image serializer.
On upload, validate that the file is a valid image file
"""
def to_representation(self, value):
if not value:
return None
return os.path.join(str(settings.MEDIA_URL), str(value))

View File

@ -58,7 +58,7 @@ function inventreeFormDataUpload(url, data, options={}) {
xhr.setRequestHeader('X-CSRFToken', csrftoken); xhr.setRequestHeader('X-CSRFToken', csrftoken);
}, },
url: url, url: url,
method: 'POST', method: options.method || 'POST',
data: data, data: data,
processData: false, processData: false,
contentType: false, contentType: false,

View File

@ -219,6 +219,7 @@ function enableDragAndDrop(element, url, options) {
data - Other form data to upload data - Other form data to upload
success - Callback function in case of success success - Callback function in case of success
error - Callback function in case of error error - Callback function in case of error
method - HTTP method
*/ */
data = options.data || {}; data = options.data || {};
@ -254,7 +255,8 @@ function enableDragAndDrop(element, url, options) {
if (options.error) { if (options.error) {
options.error(xhr, status, error); options.error(xhr, status, error);
} }
} },
method: options.method || 'POST',
} }
); );
} else { } else {

View File

@ -4,9 +4,10 @@ Provides system status functionality checks.
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.utils import timezone
import logging import logging
from datetime import datetime, timedelta from datetime import timedelta
from django_q.models import Success from django_q.models import Success
from django_q.monitor import Stat from django_q.monitor import Stat
@ -34,7 +35,7 @@ def is_worker_running(**kwargs):
Check to see if we have a result within the last 20 minutes Check to see if we have a result within the last 20 minutes
""" """
now = datetime.now() now = timezone.now()
past = now - timedelta(minutes=20) past = now - timedelta(minutes=20)
results = Success.objects.filter( results = Success.objects.filter(
@ -60,21 +61,21 @@ def is_email_configured():
# Display warning unless in test mode # Display warning unless in test mode
if not settings.TESTING: if not settings.TESTING:
logger.warning("EMAIL_HOST is not configured") logger.debug("EMAIL_HOST is not configured")
if not settings.EMAIL_HOST_USER: if not settings.EMAIL_HOST_USER:
configured = False configured = False
# Display warning unless in test mode # Display warning unless in test mode
if not settings.TESTING: if not settings.TESTING:
logger.warning("EMAIL_HOST_USER is not configured") logger.debug("EMAIL_HOST_USER is not configured")
if not settings.EMAIL_HOST_PASSWORD: if not settings.EMAIL_HOST_PASSWORD:
configured = False configured = False
# Display warning unless in test mode # Display warning unless in test mode
if not settings.TESTING: if not settings.TESTING:
logger.warning("EMAIL_HOST_PASSWORD is not configured") logger.debug("EMAIL_HOST_PASSWORD is not configured")
return configured return configured

View File

@ -28,7 +28,7 @@ def schedule_task(taskname, **kwargs):
try: try:
from django_q.models import Schedule from django_q.models import Schedule
except (AppRegistryNotReady): except (AppRegistryNotReady):
logger.warning("Could not start background tasks - App registry not ready") logger.info("Could not start background tasks - App registry not ready")
return return
try: try:
@ -80,7 +80,7 @@ def heartbeat():
try: try:
from django_q.models import Success from django_q.models import Success
logger.warning("Could not perform heartbeat task - App registry not ready") logger.info("Could not perform heartbeat task - App registry not ready")
except AppRegistryNotReady: except AppRegistryNotReady:
return return
@ -105,7 +105,7 @@ def delete_successful_tasks():
try: try:
from django_q.models import Success from django_q.models import Success
except AppRegistryNotReady: except AppRegistryNotReady:
logger.warning("Could not perform 'delete_successful_tasks' - App registry not ready") logger.info("Could not perform 'delete_successful_tasks' - App registry not ready")
return return
threshold = datetime.now() - timedelta(days=30) threshold = datetime.now() - timedelta(days=30)
@ -126,6 +126,7 @@ def check_for_updates():
import common.models import common.models
except AppRegistryNotReady: except AppRegistryNotReady:
# Apps not yet loaded! # Apps not yet loaded!
logger.info("Could not perform 'check_for_updates' - App registry not ready")
return return
response = requests.get('https://api.github.com/repos/inventree/inventree/releases/latest') response = requests.get('https://api.github.com/repos/inventree/inventree/releases/latest')
@ -172,6 +173,7 @@ def update_exchange_rates():
from django.conf import settings from django.conf import settings
except AppRegistryNotReady: except AppRegistryNotReady:
# Apps not yet loaded! # Apps not yet loaded!
logger.info("Could not perform 'update_exchange_rates' - App registry not ready")
return return
except: except:
# Other error? # Other error?

View File

@ -77,7 +77,7 @@ class APITests(InvenTreeAPITestCase):
self.assertIn('version', data) self.assertIn('version', data)
self.assertIn('instance', data) self.assertIn('instance', data)
self.assertEquals('InvenTree', data['server']) self.assertEqual('InvenTree', data['server'])
def test_role_view(self): def test_role_view(self):
""" """

View File

@ -10,19 +10,25 @@ import common.models
INVENTREE_SW_VERSION = "0.2.4 pre" INVENTREE_SW_VERSION = "0.2.4 pre"
INVENTREE_API_VERSION = 6
""" """
Increment thi API version number whenever there is a significant change to the API that any clients need to know about Increment thi API version number whenever there is a significant change to the API that any clients need to know about
v3 -> 2021-05-22: v6 -> 2021-06-23
- The updated StockItem "history tracking" now uses a different interface - Part and Company images can now be directly uploaded via the REST API
v5 -> 2021-06-21
- Adds API interface for manufacturer part parameters
v4 -> 2021-06-01 v4 -> 2021-06-01
- BOM items can now accept "variant stock" to be assigned against them - BOM items can now accept "variant stock" to be assigned against them
- Many slight API tweaks were needed to get this to work properly! - Many slight API tweaks were needed to get this to work properly!
""" v3 -> 2021-05-22:
- The updated StockItem "history tracking" now uses a different interface
INVENTREE_API_VERSION = 4 """
def inventreeInstanceName(): def inventreeInstanceName():

View File

@ -337,7 +337,7 @@ class AjaxMixin(InvenTreeRoleMixin):
# Do nothing by default # Do nothing by default
pass pass
def renderJsonResponse(self, request, form=None, data={}, context=None): def renderJsonResponse(self, request, form=None, data=None, context=None):
""" Render a JSON response based on specific class context. """ Render a JSON response based on specific class context.
Args: Args:
@ -349,6 +349,9 @@ class AjaxMixin(InvenTreeRoleMixin):
Returns: Returns:
JSON response object JSON response object
""" """
# a empty dict as default can be dangerous - set it here if empty
if not data:
data = {}
if not request.is_ajax(): if not request.is_ajax():
return HttpResponseRedirect('/') return HttpResponseRedirect('/')

View File

@ -40,7 +40,8 @@ def assign_bom_items(apps, schema_editor):
except BomItem.DoesNotExist: except BomItem.DoesNotExist:
pass pass
print(f"Assigned BomItem for {count_valid}/{count_total} entries") if count_total > 0:
print(f"Assigned BomItem for {count_valid}/{count_total} entries")
def unassign_bom_items(apps, schema_editor): def unassign_bom_items(apps, schema_editor):

View File

@ -212,6 +212,13 @@ class InvenTreeSetting(models.Model):
'validator': bool, 'validator': bool,
}, },
'PART_SHOW_PRICE_IN_FORMS': {
'name': _('Show Price in Forms'),
'description': _('Display part price in some forms'),
'default': True,
'validator': bool,
},
'PART_INTERNAL_PRICE': { 'PART_INTERNAL_PRICE': {
'name': _('Internal Prices'), 'name': _('Internal Prices'),
'description': _('Enable internal prices for parts'), 'description': _('Enable internal prices for parts'),

View File

@ -11,6 +11,7 @@ import import_export.widgets as widgets
from .models import Company from .models import Company
from .models import SupplierPart from .models import SupplierPart
from .models import SupplierPriceBreak from .models import SupplierPriceBreak
from .models import ManufacturerPart, ManufacturerPartParameter
from part.models import Part from part.models import Part
@ -71,6 +72,92 @@ class SupplierPartAdmin(ImportExportModelAdmin):
] ]
class ManufacturerPartResource(ModelResource):
"""
Class for managing ManufacturerPart data import/export
"""
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
part_name = Field(attribute='part__full_name', readonly=True)
manufacturer = Field(attribute='manufacturer', widget=widgets.ForeignKeyWidget(Company))
manufacturer_name = Field(attribute='manufacturer__name', readonly=True)
class Meta:
model = ManufacturerPart
skip_unchanged = True
report_skipped = True
clean_model_instances = True
class ManufacturerPartParameterInline(admin.TabularInline):
"""
Inline for editing ManufacturerPartParameter objects,
directly from the ManufacturerPart admin view.
"""
model = ManufacturerPartParameter
class SupplierPartInline(admin.TabularInline):
"""
Inline for the SupplierPart model
"""
model = SupplierPart
class ManufacturerPartAdmin(ImportExportModelAdmin):
"""
Admin class for ManufacturerPart model
"""
resource_class = ManufacturerPartResource
list_display = ('part', 'manufacturer', 'MPN')
search_fields = [
'manufacturer__name',
'part__name',
'MPN',
]
inlines = [
SupplierPartInline,
ManufacturerPartParameterInline,
]
class ManufacturerPartParameterResource(ModelResource):
"""
Class for managing ManufacturerPartParameter data import/export
"""
class Meta:
model = ManufacturerPartParameter
skip_unchanged = True
report_skipped = True
clean_model_instance = True
class ManufacturerPartParameterAdmin(ImportExportModelAdmin):
"""
Admin class for ManufacturerPartParameter model
"""
resource_class = ManufacturerPartParameterResource
list_display = ('manufacturer_part', 'name', 'value')
search_fields = [
'manufacturer_part__manufacturer__name',
'name',
'value'
]
class SupplierPriceBreakResource(ModelResource): class SupplierPriceBreakResource(ModelResource):
""" Class for managing SupplierPriceBreak data import/export """ """ Class for managing SupplierPriceBreak data import/export """
@ -103,3 +190,6 @@ class SupplierPriceBreakAdmin(ImportExportModelAdmin):
admin.site.register(Company, CompanyAdmin) admin.site.register(Company, CompanyAdmin)
admin.site.register(SupplierPart, SupplierPartAdmin) admin.site.register(SupplierPart, SupplierPartAdmin)
admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin) admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin)
admin.site.register(ManufacturerPart, ManufacturerPartAdmin)
admin.site.register(ManufacturerPartParameter, ManufacturerPartParameterAdmin)

View File

@ -15,11 +15,11 @@ from django.db.models import Q
from InvenTree.helpers import str2bool from InvenTree.helpers import str2bool
from .models import Company from .models import Company
from .models import ManufacturerPart from .models import ManufacturerPart, ManufacturerPartParameter
from .models import SupplierPart, SupplierPriceBreak from .models import SupplierPart, SupplierPriceBreak
from .serializers import CompanySerializer from .serializers import CompanySerializer
from .serializers import ManufacturerPartSerializer from .serializers import ManufacturerPartSerializer, ManufacturerPartParameterSerializer
from .serializers import SupplierPartSerializer, SupplierPriceBreakSerializer from .serializers import SupplierPartSerializer, SupplierPriceBreakSerializer
@ -175,6 +175,86 @@ class ManufacturerPartDetail(generics.RetrieveUpdateDestroyAPIView):
serializer_class = ManufacturerPartSerializer serializer_class = ManufacturerPartSerializer
class ManufacturerPartParameterList(generics.ListCreateAPIView):
"""
API endpoint for list view of ManufacturerPartParamater model.
"""
queryset = ManufacturerPartParameter.objects.all()
serializer_class = ManufacturerPartParameterSerializer
def get_serializer(self, *args, **kwargs):
# Do we wish to include any extra detail?
try:
params = self.request.query_params
optional_fields = [
'manufacturer_part_detail',
]
for key in optional_fields:
kwargs[key] = str2bool(params.get(key, None))
except AttributeError:
pass
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
def filter_queryset(self, queryset):
"""
Custom filtering for the queryset
"""
queryset = super().filter_queryset(queryset)
params = self.request.query_params
# Filter by manufacturer?
manufacturer = params.get('manufacturer', None)
if manufacturer is not None:
queryset = queryset.filter(manufacturer_part__manufacturer=manufacturer)
# Filter by part?
part = params.get('part', None)
if part is not None:
queryset = queryset.filter(manufacturer_part__part=part)
return queryset
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
filter_fields = [
'name',
'value',
'units',
'manufacturer_part',
]
search_fields = [
'name',
'value',
'units',
]
class ManufacturerPartParameterDetail(generics.RetrieveUpdateDestroyAPIView):
"""
API endpoint for detail view of ManufacturerPartParameter model
"""
queryset = ManufacturerPartParameter.objects.all()
serializer_class = ManufacturerPartParameterSerializer
class SupplierPartList(generics.ListCreateAPIView): class SupplierPartList(generics.ListCreateAPIView):
""" API endpoint for list view of SupplierPart object """ API endpoint for list view of SupplierPart object
@ -249,7 +329,7 @@ class SupplierPartList(generics.ListCreateAPIView):
params = self.request.query_params params = self.request.query_params
kwargs['part_detail'] = str2bool(params.get('part_detail', None)) kwargs['part_detail'] = str2bool(params.get('part_detail', None))
kwargs['supplier_detail'] = str2bool(params.get('supplier_detail', None)) kwargs['supplier_detail'] = str2bool(params.get('supplier_detail', None))
kwargs['manufacturer_detail'] = str2bool(self.params.get('manufacturer_detail', None)) kwargs['manufacturer_detail'] = str2bool(params.get('manufacturer_detail', None))
kwargs['pretty'] = str2bool(params.get('pretty', None)) kwargs['pretty'] = str2bool(params.get('pretty', None))
except AttributeError: except AttributeError:
pass pass
@ -316,6 +396,13 @@ class SupplierPriceBreakList(generics.ListCreateAPIView):
manufacturer_part_api_urls = [ manufacturer_part_api_urls = [
url(r'^parameter/', include([
url(r'^(?P<pk>\d+)/', ManufacturerPartParameterDetail.as_view(), name='api-manufacturer-part-parameter-detail'),
# Catch anything else
url(r'^.*$', ManufacturerPartParameterList.as_view(), name='api-manufacturer-part-parameter-list'),
])),
url(r'^(?P<pk>\d+)/?', ManufacturerPartDetail.as_view(), name='api-manufacturer-part-detail'), url(r'^(?P<pk>\d+)/?', ManufacturerPartDetail.as_view(), name='api-manufacturer-part-detail'),
# Catch anything else # Catch anything else

View File

@ -44,8 +44,6 @@ class CompanyConfig(AppConfig):
company.image.render_variations(replace=False) company.image.render_variations(replace=False)
except FileNotFoundError: except FileNotFoundError:
logger.warning(f"Image file '{company.image}' missing") logger.warning(f"Image file '{company.image}' missing")
company.image = None
company.save()
except UnidentifiedImageError: except UnidentifiedImageError:
logger.warning(f"Image file '{company.image}' is invalid") logger.warning(f"Image file '{company.image}' is invalid")
except (OperationalError, ProgrammingError): except (OperationalError, ProgrammingError):

View File

@ -16,7 +16,7 @@ from djmoney.forms.fields import MoneyField
from common.settings import currency_code_default from common.settings import currency_code_default
from .models import Company from .models import Company, ManufacturerPartParameter
from .models import ManufacturerPart from .models import ManufacturerPart
from .models import SupplierPart from .models import SupplierPart
from .models import SupplierPriceBreak from .models import SupplierPriceBreak
@ -105,6 +105,21 @@ class EditManufacturerPartForm(HelperForm):
] ]
class EditManufacturerPartParameterForm(HelperForm):
"""
Form for creating / editing a ManufacturerPartParameter object
"""
class Meta:
model = ManufacturerPartParameter
fields = [
'manufacturer_part',
'name',
'value',
'units',
]
class EditSupplierPartForm(HelperForm): class EditSupplierPartForm(HelperForm):
""" Form for editing a SupplierPart object """ """ Form for editing a SupplierPart object """

View File

@ -71,7 +71,8 @@ def migrate_currencies(apps, schema_editor):
count += 1 count += 1
print(f"Updated {count} SupplierPriceBreak rows") if count > 0:
print(f"Updated {count} SupplierPriceBreak rows")
def reverse_currencies(apps, schema_editor): def reverse_currencies(apps, schema_editor):
""" """

View File

@ -0,0 +1,27 @@
# Generated by Django 3.2.4 on 2021-06-20 07:48
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('company', '0037_supplierpart_update_3'),
]
operations = [
migrations.CreateModel(
name='ManufacturerPartParameter',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Parameter name', max_length=500, verbose_name='Name')),
('value', models.CharField(help_text='Parameter value', max_length=500, verbose_name='Value')),
('units', models.CharField(blank=True, help_text='Parameter units', max_length=64, null=True, verbose_name='Units')),
('manufacturer_part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='parameters', to='company.manufacturerpart', verbose_name='Manufacturer Part')),
],
options={
'unique_together': {('manufacturer_part', 'name')},
},
),
]

View File

@ -371,6 +371,47 @@ class ManufacturerPart(models.Model):
return s return s
class ManufacturerPartParameter(models.Model):
"""
A ManufacturerPartParameter represents a key:value parameter for a MnaufacturerPart.
This is used to represent parmeters / properties for a particular manufacturer part.
Each parameter is a simple string (text) value.
"""
class Meta:
unique_together = ('manufacturer_part', 'name')
manufacturer_part = models.ForeignKey(
ManufacturerPart,
on_delete=models.CASCADE,
related_name='parameters',
verbose_name=_('Manufacturer Part'),
)
name = models.CharField(
max_length=500,
blank=False,
verbose_name=_('Name'),
help_text=_('Parameter name')
)
value = models.CharField(
max_length=500,
blank=False,
verbose_name=_('Value'),
help_text=_('Parameter value')
)
units = models.CharField(
max_length=64,
blank=True, null=True,
verbose_name=_('Units'),
help_text=_('Parameter units')
)
class SupplierPart(models.Model): class SupplierPart(models.Model):
""" Represents a unique part as provided by a Supplier """ Represents a unique part as provided by a Supplier
Each SupplierPart is identified by a SKU (Supplier Part Number) Each SupplierPart is identified by a SKU (Supplier Part Number)

View File

@ -6,14 +6,15 @@ from rest_framework import serializers
from sql_util.utils import SubqueryCount from sql_util.utils import SubqueryCount
from .models import Company
from .models import ManufacturerPart
from .models import SupplierPart, SupplierPriceBreak
from InvenTree.serializers import InvenTreeModelSerializer from InvenTree.serializers import InvenTreeModelSerializer
from InvenTree.serializers import InvenTreeImageSerializerField
from part.serializers import PartBriefSerializer from part.serializers import PartBriefSerializer
from .models import Company
from .models import ManufacturerPart, ManufacturerPartParameter
from .models import SupplierPart, SupplierPriceBreak
class CompanyBriefSerializer(InvenTreeModelSerializer): class CompanyBriefSerializer(InvenTreeModelSerializer):
""" Serializer for Company object (limited detail) """ """ Serializer for Company object (limited detail) """
@ -52,7 +53,7 @@ class CompanySerializer(InvenTreeModelSerializer):
url = serializers.CharField(source='get_absolute_url', read_only=True) url = serializers.CharField(source='get_absolute_url', read_only=True)
image = serializers.CharField(source='get_thumbnail_url', read_only=True) image = InvenTreeImageSerializerField(required=False, allow_null=True)
parts_supplied = serializers.IntegerField(read_only=True) parts_supplied = serializers.IntegerField(read_only=True)
parts_manufactured = serializers.IntegerField(read_only=True) parts_manufactured = serializers.IntegerField(read_only=True)
@ -124,6 +125,35 @@ class ManufacturerPartSerializer(InvenTreeModelSerializer):
] ]
class ManufacturerPartParameterSerializer(InvenTreeModelSerializer):
"""
Serializer for the ManufacturerPartParameter model
"""
manufacturer_part_detail = ManufacturerPartSerializer(source='manufacturer_part', many=False, read_only=True)
def __init__(self, *args, **kwargs):
man_detail = kwargs.pop('manufacturer_part_detail', False)
super(ManufacturerPartParameterSerializer, self).__init__(*args, **kwargs)
if not man_detail:
self.fields.pop('manufacturer_part_detail')
class Meta:
model = ManufacturerPartParameter
fields = [
'pk',
'manufacturer_part',
'manufacturer_part_detail',
'name',
'value',
'units',
]
class SupplierPartSerializer(InvenTreeModelSerializer): class SupplierPartSerializer(InvenTreeModelSerializer):
""" Serializer for SupplierPart object """ """ Serializer for SupplierPart object """

View File

@ -139,11 +139,17 @@
enableDragAndDrop( enableDragAndDrop(
"#company-thumb", "#company-thumb",
"{% url 'company-image' company.id %}", "{% url 'api-company-detail' company.id %}",
{ {
label: 'image', label: 'image',
method: 'PATCH',
success: function(data, status, xhr) { success: function(data, status, xhr) {
location.reload();
if (data.image) {
$('#company-image').attr('src', data.image);
} else {
location.reload();
}
} }
} }
); );

View File

@ -7,7 +7,7 @@
{% endblock %} {% endblock %}
{% block heading %} {% block heading %}
{% trans "Supplier Parts" %} {% trans "Suppliers" %}
{% endblock %} {% endblock %}
{% block details %} {% block details %}
@ -30,9 +30,44 @@
{% endblock %} {% endblock %}
{% block post_content_panels %}
<div class='panel panel-default panel-inventree'>
<div class='panel-heading'>
<h4>{% trans "Parameters" %}</h4>
</div>
<div class='panel-content'>
<div id='parameter-toolbar'>
<div class='btn-group'>
<button class='btn btn-success' id='parameter-create'>
<span class='fas fa-plus-circle'></span> {% trans "New Parameter" %}
</button>
<div id='param-dropdown' class='btn-group'>
<!-- TODO -->
</div>
</div>
</div>
<table class='table table-striped table-condensed' id='parameter-table' data-toolbar='#parameter-toolbar'></table>
</div>
</div>
{% endblock %}
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
$('#parameter-create').click(function() {
launchModalForm(
"{% url 'manufacturer-part-parameter-create' %}",
{
data: {
manufacturer_part: {{ part.id }},
}
}
);
});
$('#supplier-create').click(function () { $('#supplier-create').click(function () {
launchModalForm( launchModalForm(
"{% url 'supplier-part-create' %}", "{% url 'supplier-part-create' %}",
@ -84,6 +119,16 @@ loadSupplierPartTable(
} }
); );
loadManufacturerPartParameterTable(
"#parameter-table",
"{% url 'api-manufacturer-part-parameter-list' %}",
{
params: {
manufacturer_part: {{ part.id }},
}
}
);
linkButtonsToSelection($("#supplier-table"), ['#supplier-part-options']) linkButtonsToSelection($("#supplier-table"), ['#supplier-part-options'])
{% endblock %} {% endblock %}

View File

@ -50,10 +50,15 @@ class CompanyTest(InvenTreeAPITestCase):
self.assertEqual(response.data['name'], 'ACME') self.assertEqual(response.data['name'], 'ACME')
# Change the name of the company # Change the name of the company
# Note we should not have the correct permissions (yet)
data = response.data data = response.data
data['name'] = 'ACMOO' data['name'] = 'ACMOO'
response = self.client.patch(url, data, format='json') response = self.client.patch(url, data, format='json', expected_code=400)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assignRole('company.change')
response = self.client.patch(url, data, format='json', expected_code=200)
self.assertEqual(response.data['name'], 'ACMOO') self.assertEqual(response.data['name'], 'ACMOO')
def test_company_search(self): def test_company_search(self):
@ -119,7 +124,9 @@ class ManufacturerTest(InvenTreeAPITestCase):
data = { data = {
'MPN': 'MPN-TEST-123', 'MPN': 'MPN-TEST-123',
} }
response = self.client.patch(url, data, format='json') response = self.client.patch(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['MPN'], 'MPN-TEST-123') self.assertEqual(response.data['MPN'], 'MPN-TEST-123')

View File

@ -53,20 +53,25 @@ price_break_urls = [
url(r'^(?P<pk>\d+)/delete/', views.PriceBreakDelete.as_view(), name='price-break-delete'), url(r'^(?P<pk>\d+)/delete/', views.PriceBreakDelete.as_view(), name='price-break-delete'),
] ]
manufacturer_part_detail_urls = [
url(r'^edit/?', views.ManufacturerPartEdit.as_view(), name='manufacturer-part-edit'),
url(r'^suppliers/', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part_suppliers.html'), name='manufacturer-part-suppliers'),
url('^.*$', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part_suppliers.html'), name='manufacturer-part-detail'),
]
manufacturer_part_urls = [ manufacturer_part_urls = [
url(r'^new/?', views.ManufacturerPartCreate.as_view(), name='manufacturer-part-create'), url(r'^new/?', views.ManufacturerPartCreate.as_view(), name='manufacturer-part-create'),
url(r'delete/', views.ManufacturerPartDelete.as_view(), name='manufacturer-part-delete'), url(r'^delete/', views.ManufacturerPartDelete.as_view(), name='manufacturer-part-delete'),
url(r'^(?P<pk>\d+)/', include(manufacturer_part_detail_urls)), # URLs for ManufacturerPartParameter views (create / edit / delete)
url(r'^parameter/', include([
url(r'^new/', views.ManufacturerPartParameterCreate.as_view(), name='manufacturer-part-parameter-create'),
url(r'^(?P<pk>\d)/', include([
url(r'^edit/', views.ManufacturerPartParameterEdit.as_view(), name='manufacturer-part-parameter-edit'),
url(r'^delete/', views.ManufacturerPartParameterDelete.as_view(), name='manufacturer-part-parameter-delete'),
])),
])),
url(r'^(?P<pk>\d+)/', include([
url(r'^edit/?', views.ManufacturerPartEdit.as_view(), name='manufacturer-part-edit'),
url(r'^suppliers/', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part_suppliers.html'), name='manufacturer-part-suppliers'),
url('^.*$', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part_suppliers.html'), name='manufacturer-part-detail'),
])),
] ]
supplier_part_detail_urls = [ supplier_part_detail_urls = [

View File

@ -23,14 +23,14 @@ from InvenTree.views import AjaxCreateView, AjaxUpdateView, AjaxDeleteView
from InvenTree.helpers import str2bool from InvenTree.helpers import str2bool
from InvenTree.views import InvenTreeRoleMixin from InvenTree.views import InvenTreeRoleMixin
from .models import Company from .models import Company, ManufacturerPartParameter
from .models import ManufacturerPart from .models import ManufacturerPart
from .models import SupplierPart from .models import SupplierPart
from .models import SupplierPriceBreak from .models import SupplierPriceBreak
from part.models import Part from part.models import Part
from .forms import EditCompanyForm from .forms import EditCompanyForm, EditManufacturerPartParameterForm
from .forms import CompanyImageForm from .forms import CompanyImageForm
from .forms import EditManufacturerPartForm from .forms import EditManufacturerPartForm
from .forms import EditSupplierPartForm from .forms import EditSupplierPartForm
@ -504,6 +504,66 @@ class ManufacturerPartDelete(AjaxDeleteView):
return self.renderJsonResponse(self.request, data=data, form=self.get_form()) return self.renderJsonResponse(self.request, data=data, form=self.get_form())
class ManufacturerPartParameterCreate(AjaxCreateView):
"""
View for creating a new ManufacturerPartParameter object
"""
model = ManufacturerPartParameter
form_class = EditManufacturerPartParameterForm
ajax_form_title = _('Add Manufacturer Part Parameter')
def get_form(self):
form = super().get_form()
# Hide the manufacturer_part field if specified
if form.initial.get('manufacturer_part', None):
form.fields['manufacturer_part'].widget = HiddenInput()
return form
def get_initial(self):
initials = super().get_initial().copy()
manufacturer_part = self.get_param('manufacturer_part')
if manufacturer_part:
try:
initials['manufacturer_part'] = ManufacturerPartParameter.objects.get(pk=manufacturer_part)
except (ValueError, ManufacturerPartParameter.DoesNotExist):
pass
return initials
class ManufacturerPartParameterEdit(AjaxUpdateView):
"""
View for editing a ManufacturerPartParameter object
"""
model = ManufacturerPartParameter
form_class = EditManufacturerPartParameterForm
ajax_form_title = _('Edit Manufacturer Part Parameter')
def get_form(self):
form = super().get_form()
form.fields['manufacturer_part'].widget = HiddenInput()
return form
class ManufacturerPartParameterDelete(AjaxDeleteView):
"""
View for deleting a ManufacturerPartParameter object
"""
model = ManufacturerPartParameter
class SupplierPartDetail(DetailView): class SupplierPartDetail(DetailView):
""" Detail view for SupplierPart """ """ Detail view for SupplierPart """
model = SupplierPart model = SupplierPart
@ -563,7 +623,8 @@ class SupplierPartEdit(AjaxUpdateView):
supplier_part = self.get_object() supplier_part = self.get_object()
if supplier_part.manufacturer_part: if supplier_part.manufacturer_part:
initials['manufacturer'] = supplier_part.manufacturer_part.manufacturer.id if supplier_part.manufacturer_part.manufacturer:
initials['manufacturer'] = supplier_part.manufacturer_part.manufacturer.id
initials['MPN'] = supplier_part.manufacturer_part.MPN initials['MPN'] = supplier_part.manufacturer_part.MPN
return initials return initials

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -157,7 +157,7 @@ class POList(generics.ListCreateAPIView):
ordering = '-creation_date' ordering = '-creation_date'
class PODetail(generics.RetrieveUpdateAPIView): class PODetail(generics.RetrieveUpdateDestroyAPIView):
""" API endpoint for detail view of a PurchaseOrder object """ """ API endpoint for detail view of a PurchaseOrder object """
queryset = PurchaseOrder.objects.all() queryset = PurchaseOrder.objects.all()
@ -382,7 +382,7 @@ class SOList(generics.ListCreateAPIView):
ordering = '-creation_date' ordering = '-creation_date'
class SODetail(generics.RetrieveUpdateAPIView): class SODetail(generics.RetrieveUpdateDestroyAPIView):
""" """
API endpoint for detail view of a SalesOrder object. API endpoint for detail view of a SalesOrder object.
""" """

View File

@ -92,8 +92,10 @@ class POSerializer(InvenTreeModelSerializer):
] ]
read_only_fields = [ read_only_fields = [
'reference',
'status' 'status'
'issue_date',
'complete_date',
'creation_date',
] ]
@ -109,8 +111,9 @@ class POLineItemSerializer(InvenTreeModelSerializer):
self.fields.pop('part_detail') self.fields.pop('part_detail')
self.fields.pop('supplier_part_detail') self.fields.pop('supplier_part_detail')
quantity = serializers.FloatField() # TODO: Once https://github.com/inventree/InvenTree/issues/1687 is fixed, remove default values
received = serializers.FloatField() quantity = serializers.FloatField(default=1)
received = serializers.FloatField(default=0)
part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True) part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True)
supplier_part_detail = SupplierPartSerializer(source='part', many=False, read_only=True) supplier_part_detail = SupplierPartSerializer(source='part', many=False, read_only=True)
@ -225,8 +228,9 @@ class SalesOrderSerializer(InvenTreeModelSerializer):
] ]
read_only_fields = [ read_only_fields = [
'reference', 'status',
'status' 'creation_date',
'shipment_date',
] ]
@ -312,7 +316,9 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
part_detail = PartBriefSerializer(source='part', many=False, read_only=True) part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
allocations = SalesOrderAllocationSerializer(many=True, read_only=True) allocations = SalesOrderAllocationSerializer(many=True, read_only=True)
quantity = serializers.FloatField() # TODO: Once https://github.com/inventree/InvenTree/issues/1687 is fixed, remove default values
quantity = serializers.FloatField(default=1)
allocated = serializers.FloatField(source='allocated_quantity', read_only=True) allocated = serializers.FloatField(source='allocated_quantity', read_only=True)
fulfilled = serializers.FloatField(source='fulfilled_quantity', read_only=True) fulfilled = serializers.FloatField(source='fulfilled_quantity', read_only=True)
sale_price_string = serializers.CharField(source='sale_price', read_only=True) sale_price_string = serializers.CharField(source='sale_price', read_only=True)

View File

@ -75,7 +75,7 @@ src="{% static 'img/blank_image.png' %}"
<tr> <tr>
<td><span class='fas fa-hashtag'></span></td> <td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Order Reference" %}</td> <td>{% trans "Order Reference" %}</td>
<td>{{ order.reference }}{% include "clip.html"%}</td> <td>{% settings_value 'PURCHASEORDER_REFERENCE_PREFIX' %}{{ order.reference }}{% include "clip.html"%}</td>
</tr> </tr>
<tr> <tr>
<td><span class='fas fa-info'></span></td> <td><span class='fas fa-info'></span></td>

View File

@ -4,6 +4,8 @@
{% load i18n %} {% load i18n %}
{% block form %} {% block form %}
{% default_currency as currency %}
{% settings_value 'PART_SHOW_PRICE_IN_FORMS' as show_price %}
<h4> <h4>
{% trans "Step 1 of 2 - Select Part Suppliers" %} {% trans "Step 1 of 2 - Select Part Suppliers" %}
@ -49,7 +51,13 @@
<select class='select' id='id_supplier_part_{{ part.id }}' name="part-supplier-{{ part.id }}"> <select class='select' id='id_supplier_part_{{ part.id }}' name="part-supplier-{{ part.id }}">
<option value=''>---------</option> <option value=''>---------</option>
{% for supplier in part.supplier_parts.all %} {% for supplier in part.supplier_parts.all %}
<option value="{{ supplier.id }}"{% if part.order_supplier == supplier.id %} selected="selected"{% endif %}>{{ supplier }}</option> <option value="{{ supplier.id }}"{% if part.order_supplier == supplier.id %} selected="selected"{% endif %}>
{% if show_price %}
{% call_method supplier 'get_price' part.order_quantity as price %}
{% if price != None %}{% include "price.html" with price=price %}{% else %}{% trans 'No price' %}{% endif %} -
{% endif %}
{{ supplier }}
</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>

View File

@ -57,8 +57,6 @@ $("#attachment-table").on('click', '.attachment-delete-button', function() {
var url = `/order/purchase-order/attachment/${button.attr('pk')}/delete/`; var url = `/order/purchase-order/attachment/${button.attr('pk')}/delete/`;
console.log("url: " + url);
launchModalForm(url, { launchModalForm(url, {
reload: true, reload: true,
}); });

View File

@ -77,7 +77,7 @@ src="{% static 'img/blank_image.png' %}"
<tr> <tr>
<td><span class='fas fa-hashtag'></span></td> <td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Order Reference" %}</td> <td>{% trans "Order Reference" %}</td>
<td>{{ order.reference }}{% include "clip.html"%}</td> <td>{% settings_value 'SALESORDER_REFERENCE_PREFIX' %}{{ order.reference }}{% include "clip.html"%}</td>
</tr> </tr>
<tr> <tr>
<td><span class='fas fa-info'></span></td> <td><span class='fas fa-info'></span></td>

View File

@ -110,6 +110,96 @@ class PurchaseOrderTest(OrderTest):
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_po_operations(self):
"""
Test that we can create / edit and delete a PurchaseOrder via the API
"""
n = PurchaseOrder.objects.count()
url = reverse('api-po-list')
# Initially we do not have "add" permission for the PurchaseOrder model,
# so this POST request should return 403
response = self.post(
url,
{
'supplier': 1,
'reference': '123456789-xyz',
'description': 'PO created via the API',
},
expected_code=403
)
# And no new PurchaseOrder objects should have been created
self.assertEqual(PurchaseOrder.objects.count(), n)
# Ok, now let's give this user the correct permission
self.assignRole('purchase_order.add')
# Initially we do not have "add" permission for the PurchaseOrder model,
# so this POST request should return 403
response = self.post(
url,
{
'supplier': 1,
'reference': '123456789-xyz',
'description': 'PO created via the API',
},
expected_code=201
)
self.assertEqual(PurchaseOrder.objects.count(), n + 1)
pk = response.data['pk']
# Try to create a PO with identical reference (should fail!)
response = self.post(
url,
{
'supplier': 1,
'reference': '123456789-xyz',
'description': 'A different description',
},
expected_code=400
)
self.assertEqual(PurchaseOrder.objects.count(), n + 1)
url = reverse('api-po-detail', kwargs={'pk': pk})
# Get detail info!
response = self.get(url)
self.assertEqual(response.data['pk'], pk)
self.assertEqual(response.data['reference'], '123456789-xyz')
# Try to alter (edit) the PurchaseOrder
response = self.patch(
url,
{
'reference': '12345-abc',
},
expected_code=200
)
# Reference should have changed
self.assertEqual(response.data['reference'], '12345-abc')
# Now, let's try to delete it!
# Initially, we do *not* have the required permission!
response = self.delete(url, expected_code=403)
# Now, add the "delete" permission!
self.assignRole("purchase_order.delete")
response = self.delete(url, expected_code=204)
# Number of PurchaseOrder objects should have decreased
self.assertEqual(PurchaseOrder.objects.count(), n)
# And if we try to access the detail view again, it has gone
response = self.get(url, expected_code=404)
class SalesOrderTest(OrderTest): class SalesOrderTest(OrderTest):
""" """
@ -158,8 +248,6 @@ class SalesOrderTest(OrderTest):
response = self.get(url) response = self.get(url)
self.assertEqual(response.status_code, 200)
data = response.data data = response.data
self.assertEqual(data['pk'], 1) self.assertEqual(data['pk'], 1)
@ -168,6 +256,87 @@ class SalesOrderTest(OrderTest):
url = reverse('api-so-attachment-list') url = reverse('api-so-attachment-list')
response = self.get(url) self.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK) def test_so_operations(self):
"""
Test that we can create / edit and delete a SalesOrder via the API
"""
n = SalesOrder.objects.count()
url = reverse('api-so-list')
# Initially we do not have "add" permission for the SalesOrder model,
# so this POST request should return 403 (denied)
response = self.post(
url,
{
'customer': 4,
'reference': '12345',
'description': 'Sales order',
},
expected_code=403,
)
self.assignRole('sales_order.add')
# Now we should be able to create a SalesOrder via the API
response = self.post(
url,
{
'customer': 4,
'reference': '12345',
'description': 'Sales order',
},
expected_code=201
)
# Check that the new order has been created
self.assertEqual(SalesOrder.objects.count(), n + 1)
# Grab the PK for the newly created SalesOrder
pk = response.data['pk']
# Try to create a SO with identical reference (should fail)
response = self.post(
url,
{
'customer': 4,
'reference': '12345',
'description': 'Another sales order',
},
expected_code=400
)
url = reverse('api-so-detail', kwargs={'pk': pk})
# Extract detail info for the SalesOrder
response = self.get(url)
self.assertEqual(response.data['reference'], '12345')
# Try to alter (edit) the SalesOrder
response = self.patch(
url,
{
'reference': '12345-a',
},
expected_code=200
)
# Reference should have changed
self.assertEqual(response.data['reference'], '12345-a')
# Now, let's try to delete this SalesOrder
# Initially, we do not have the required permission
response = self.delete(url, expected_code=403)
self.assignRole('sales_order.delete')
response = self.delete(url, expected_code=204)
# Check that the number of sales orders has decreased
self.assertEqual(SalesOrder.objects.count(), n)
# And the resource should no longer be available
response = self.get(url, expected_code=404)

View File

@ -1010,6 +1010,15 @@ class OrderParts(AjaxView):
return ctx return ctx
def get_data(self):
""" enrich respone json data """
data = super().get_data()
# if in selection-phase, add a button to update the prices
if getattr(self, 'form_step', 'select_parts') == 'select_parts':
data['buttons'] = [{'name': 'update_price', 'title': _('Update prices')}] # set buttons
data['hideErrorMessage'] = '1' # hide the error message
return data
def get_suppliers(self): def get_suppliers(self):
""" Calculates a list of suppliers which the user will need to create POs for. """ Calculates a list of suppliers which the user will need to create POs for.
This is calculated AFTER the user finishes selecting the parts to order. This is calculated AFTER the user finishes selecting the parts to order.
@ -1244,9 +1253,10 @@ class OrderParts(AjaxView):
valid = False valid = False
if form_step == 'select_parts': if form_step == 'select_parts':
# No errors? Proceed to PO selection form # No errors? and the price-update button was not used to submit? Proceed to PO selection form
if part_errors is False: if part_errors is False and 'act-btn_update_price' not in request.POST:
self.ajax_template_name = 'order/order_wizard/select_pos.html' self.ajax_template_name = 'order/order_wizard/select_pos.html'
self.form_step = 'select_purchase_orders' # set step (important for get_data)
else: else:
self.ajax_template_name = 'order/order_wizard/select_parts.html' self.ajax_template_name = 'order/order_wizard/select_parts.html'

View File

@ -111,6 +111,13 @@ class PartCategoryResource(ModelResource):
PartCategory.objects.rebuild() PartCategory.objects.rebuild()
class PartCategoryInline(admin.TabularInline):
"""
Inline for PartCategory model
"""
model = PartCategory
class PartCategoryAdmin(ImportExportModelAdmin): class PartCategoryAdmin(ImportExportModelAdmin):
resource_class = PartCategoryResource resource_class = PartCategoryResource
@ -119,6 +126,10 @@ class PartCategoryAdmin(ImportExportModelAdmin):
search_fields = ('name', 'description') search_fields = ('name', 'description')
inlines = [
PartCategoryInline,
]
class PartRelatedAdmin(admin.ModelAdmin): class PartRelatedAdmin(admin.ModelAdmin):
''' Class to manage PartRelated objects ''' ''' Class to manage PartRelated objects '''

View File

@ -706,6 +706,7 @@ class PartList(generics.ListCreateAPIView):
'creation_date', 'creation_date',
'IPN', 'IPN',
'in_stock', 'in_stock',
'category',
] ]
# Default ordering # Default ordering

View File

@ -39,7 +39,8 @@ class PartConfig(AppConfig):
logger.debug("InvenTree: Checking Part image thumbnails") logger.debug("InvenTree: Checking Part image thumbnails")
try: try:
for part in Part.objects.all(): # Only check parts which have images
for part in Part.objects.exclude(image=None):
if part.image: if part.image:
url = part.image.thumbnail.name url = part.image.thumbnail.name
loc = os.path.join(settings.MEDIA_ROOT, url) loc = os.path.join(settings.MEDIA_ROOT, url)
@ -50,8 +51,7 @@ class PartConfig(AppConfig):
part.image.render_variations(replace=False) part.image.render_variations(replace=False)
except FileNotFoundError: except FileNotFoundError:
logger.warning(f"Image file '{part.image}' missing") logger.warning(f"Image file '{part.image}' missing")
part.image = None pass
part.save()
except UnidentifiedImageError: except UnidentifiedImageError:
logger.warning(f"Image file '{part.image}' is invalid") logger.warning(f"Image file '{part.image}' is invalid")
except (OperationalError, ProgrammingError): except (OperationalError, ProgrammingError):

View File

@ -6,7 +6,7 @@
name: 'M2x4 LPHS' name: 'M2x4 LPHS'
description: 'M2x4 low profile head screw' description: 'M2x4 low profile head screw'
category: 8 category: 8
link: www.acme.com/parts/m2x4lphs link: http://www.acme.com/parts/m2x4lphs
tree_id: 0 tree_id: 0
purchaseable: True purchaseable: True
level: 0 level: 0
@ -56,6 +56,7 @@
fields: fields:
name: 'C_22N_0805' name: 'C_22N_0805'
description: '22nF capacitor in 0805 package' description: '22nF capacitor in 0805 package'
purchaseable: true
category: 3 category: 3
tree_id: 0 tree_id: 0
level: 0 level: 0

View File

@ -71,7 +71,8 @@ def migrate_currencies(apps, schema_editor):
count += 1 count += 1
print(f"Updated {count} SupplierPriceBreak rows") if count > 0:
print(f"Updated {count} SupplierPriceBreak rows")
def reverse_currencies(apps, schema_editor): def reverse_currencies(apps, schema_editor):
""" """

View File

@ -0,0 +1,17 @@
# Generated by Django 3.2.4 on 2021-06-21 23:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0067_partinternalpricebreak'),
]
operations = [
migrations.AddConstraint(
model_name='part',
constraint=models.UniqueConstraint(fields=('name', 'IPN', 'revision'), name='unique_part'),
),
]

View File

@ -321,6 +321,9 @@ class Part(MPTTModel):
verbose_name = _("Part") verbose_name = _("Part")
verbose_name_plural = _("Parts") verbose_name_plural = _("Parts")
ordering = ['name', ] ordering = ['name', ]
constraints = [
UniqueConstraint(fields=['name', 'IPN', 'revision'], name='unique_part')
]
class MPTTMeta: class MPTTMeta:
# For legacy reasons the 'variant_of' field is used to indicate the MPTT parent # For legacy reasons the 'variant_of' field is used to indicate the MPTT parent
@ -379,8 +382,7 @@ class Part(MPTTModel):
logger.info(f"Deleting unused image file '{previous.image}'") logger.info(f"Deleting unused image file '{previous.image}'")
previous.image.delete(save=False) previous.image.delete(save=False)
self.clean() self.full_clean()
self.validate_unique()
super().save(*args, **kwargs) super().save(*args, **kwargs)
@ -643,23 +645,6 @@ class Part(MPTTModel):
'IPN': _('Duplicate IPN not allowed in part settings'), 'IPN': _('Duplicate IPN not allowed in part settings'),
}) })
# Part name uniqueness should be case insensitive
try:
parts = Part.objects.exclude(id=self.id).filter(
name__iexact=self.name,
IPN__iexact=self.IPN,
revision__iexact=self.revision)
if parts.exists():
msg = _("Part must be unique for name, IPN and revision")
raise ValidationError({
"name": msg,
"IPN": msg,
"revision": msg,
})
except Part.DoesNotExist:
pass
def clean(self): def clean(self):
""" """
Perform cleaning operations for the Part model Perform cleaning operations for the Part model
@ -1494,16 +1479,17 @@ class Part(MPTTModel):
return True return True
def get_price_info(self, quantity=1, buy=True, bom=True): def get_price_info(self, quantity=1, buy=True, bom=True, internal=False):
""" Return a simplified pricing string for this part """ Return a simplified pricing string for this part
Args: Args:
quantity: Number of units to calculate price for quantity: Number of units to calculate price for
buy: Include supplier pricing (default = True) buy: Include supplier pricing (default = True)
bom: Include BOM pricing (default = True) bom: Include BOM pricing (default = True)
internal: Include internal pricing (default = False)
""" """
price_range = self.get_price_range(quantity, buy, bom) price_range = self.get_price_range(quantity, buy, bom, internal)
if price_range is None: if price_range is None:
return None return None
@ -1591,9 +1577,10 @@ class Part(MPTTModel):
- Supplier price (if purchased from suppliers) - Supplier price (if purchased from suppliers)
- BOM price (if built from other parts) - BOM price (if built from other parts)
- Internal price (if set for the part)
Returns: Returns:
Minimum of the supplier price or BOM price. If no pricing available, returns None Minimum of the supplier, BOM or internal price. If no pricing available, returns None
""" """
# only get internal price if set and should be used # only get internal price if set and should be used
@ -2514,7 +2501,9 @@ class BomItem(models.Model):
def price_range(self): def price_range(self):
""" Return the price-range for this BOM item. """ """ Return the price-range for this BOM item. """
prange = self.sub_part.get_price_range(self.quantity) # get internal price setting
use_internal = common.models.InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False)
prange = self.sub_part.get_price_range(self.quantity, intenal=use_internal)
if prange is None: if prange is None:
return prange return prange

View File

@ -7,12 +7,15 @@ from decimal import Decimal
from django.db import models from django.db import models
from django.db.models import Q from django.db.models import Q
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
InvenTreeModelSerializer)
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
from rest_framework import serializers from rest_framework import serializers
from sql_util.utils import SubqueryCount, SubquerySum from sql_util.utils import SubqueryCount, SubquerySum
from djmoney.contrib.django_rest_framework import MoneyField from djmoney.contrib.django_rest_framework import MoneyField
from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
InvenTreeImageSerializerField,
InvenTreeModelSerializer)
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
from stock.models import StockItem from stock.models import StockItem
from .models import (BomItem, Part, PartAttachment, PartCategory, from .models import (BomItem, Part, PartAttachment, PartCategory,
@ -300,7 +303,7 @@ class PartSerializer(InvenTreeModelSerializer):
stock_item_count = serializers.IntegerField(read_only=True) stock_item_count = serializers.IntegerField(read_only=True)
suppliers = serializers.IntegerField(read_only=True) suppliers = serializers.IntegerField(read_only=True)
image = serializers.CharField(source='get_image_url', read_only=True) image = InvenTreeImageSerializerField(required=False, allow_null=True)
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True) thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
starred = serializers.SerializerMethodField() starred = serializers.SerializerMethodField()

View File

@ -138,7 +138,7 @@
<hr> <hr>
<h4>{% trans 'Stock Pricing' %}<i class="fas fa-info-circle" title="Shows the purchase prices of stock for this part. <h4>{% trans 'Stock Pricing' %}<i class="fas fa-info-circle" title="Shows the purchase prices of stock for this part.
The part single price is the current purchase price for that supplier part."></i></h4> The part single price is the current purchase price for that supplier part."></i></h4>
{% if price_history|length > 1 %} {% if price_history|length > 0 %}
<div style="max-width: 99%; min-height: 300px"> <div style="max-width: 99%; min-height: 300px">
<canvas id="StockPriceChart"></canvas> <canvas id="StockPriceChart"></canvas>
</div> </div>

View File

@ -239,11 +239,19 @@
enableDragAndDrop( enableDragAndDrop(
'#part-thumb', '#part-thumb',
"{% url 'part-image-upload' part.id %}", "{% url 'api-part-detail' part.id %}",
{ {
label: 'image', label: 'image',
method: 'PATCH',
success: function(data, status, xhr) { success: function(data, status, xhr) {
location.reload();
// If image / thumbnail data present, live update
if (data.image) {
$('#part-image').attr('src', data.image);
} else {
// Otherwise, reload the page
location.reload();
}
} }
} }
); );

View File

@ -5,6 +5,7 @@ over and above the built-in Django tags.
""" """
import os import os
import sys
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.conf import settings as djangosettings from django.conf import settings as djangosettings
@ -114,6 +115,14 @@ def inventree_title(*args, **kwargs):
return version.inventreeInstanceTitle() return version.inventreeInstanceTitle()
@register.simple_tag()
def python_version(*args, **kwargs):
"""
Return the current python version
"""
return sys.version.split(' ')[0]
@register.simple_tag() @register.simple_tag()
def inventree_version(*args, **kwargs): def inventree_version(*args, **kwargs):
""" Return InvenTree version string """ """ Return InvenTree version string """

View File

@ -1,14 +1,19 @@
from rest_framework import status # -*- coding: utf-8 -*-
import PIL
from django.urls import reverse from django.urls import reverse
from part.models import Part from rest_framework import status
from stock.models import StockItem from rest_framework.test import APIClient
from company.models import Company
from InvenTree.api_tester import InvenTreeAPITestCase from InvenTree.api_tester import InvenTreeAPITestCase
from InvenTree.status_codes import StockStatus from InvenTree.status_codes import StockStatus
from part.models import Part, PartCategory
from stock.models import StockItem
from company.models import Company
class PartAPITest(InvenTreeAPITestCase): class PartAPITest(InvenTreeAPITestCase):
""" """
@ -230,6 +235,18 @@ class PartAPITest(InvenTreeAPITestCase):
response = self.client.get(url, data={'part': 10004}) response = self.client.get(url, data={'part': 10004})
self.assertEqual(len(response.data), 7) self.assertEqual(len(response.data), 7)
# Try to post a new object (missing description)
response = self.client.post(
url,
data={
'part': 10000,
'test_name': 'My very first test',
'required': False,
}
)
self.assertEqual(response.status_code, 400)
# Try to post a new object (should succeed) # Try to post a new object (should succeed)
response = self.client.post( response = self.client.post(
url, url,
@ -237,6 +254,7 @@ class PartAPITest(InvenTreeAPITestCase):
'part': 10000, 'part': 10000,
'test_name': 'New Test', 'test_name': 'New Test',
'required': True, 'required': True,
'description': 'a test description'
}, },
format='json', format='json',
) )
@ -248,7 +266,8 @@ class PartAPITest(InvenTreeAPITestCase):
url, url,
data={ data={
'part': 10004, 'part': 10004,
'test_name': " newtest" 'test_name': " newtest",
'description': 'dafsdf',
}, },
format='json', format='json',
) )
@ -293,6 +312,239 @@ class PartAPITest(InvenTreeAPITestCase):
self.assertEqual(len(data['results']), n) self.assertEqual(len(data['results']), n)
class PartDetailTests(InvenTreeAPITestCase):
"""
Test that we can create / edit / delete Part objects via the API
"""
fixtures = [
'category',
'part',
'location',
'bom',
'test_templates',
]
roles = [
'part.change',
'part.add',
'part.delete',
'part_category.change',
'part_category.add',
]
def setUp(self):
super().setUp()
def test_part_operations(self):
n = Part.objects.count()
# Create a part
response = self.client.post(
reverse('api-part-list'),
{
'name': 'my test api part',
'description': 'a part created with the API',
'category': 1,
}
)
self.assertEqual(response.status_code, 201)
pk = response.data['pk']
# Check that a new part has been added
self.assertEqual(Part.objects.count(), n + 1)
part = Part.objects.get(pk=pk)
self.assertEqual(part.name, 'my test api part')
# Edit the part
url = reverse('api-part-detail', kwargs={'pk': pk})
# Let's change the name of the part
response = self.client.patch(url, {
'name': 'a new better name',
})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['pk'], pk)
self.assertEqual(response.data['name'], 'a new better name')
part = Part.objects.get(pk=pk)
# Name has been altered
self.assertEqual(part.name, 'a new better name')
# Part count should not have changed
self.assertEqual(Part.objects.count(), n + 1)
# Now, try to set the name to the *same* value
# 2021-06-22 this test is to check that the "duplicate part" checks don't do strange things
response = self.client.patch(url, {
'name': 'a new better name',
})
self.assertEqual(response.status_code, 200)
# Try to remove the part
response = self.client.delete(url)
self.assertEqual(response.status_code, 204)
# Part count should have reduced
self.assertEqual(Part.objects.count(), n)
def test_duplicates(self):
"""
Check that trying to create 'duplicate' parts results in errors
"""
# Create a part
response = self.client.post(reverse('api-part-list'), {
'name': 'part',
'description': 'description',
'IPN': 'IPN-123',
'category': 1,
'revision': 'A',
})
self.assertEqual(response.status_code, 201)
n = Part.objects.count()
# Check that we cannot create a duplicate in a different category
response = self.client.post(reverse('api-part-list'), {
'name': 'part',
'description': 'description',
'IPN': 'IPN-123',
'category': 2,
'revision': 'A',
})
self.assertEqual(response.status_code, 400)
# Check that only 1 matching part exists
parts = Part.objects.filter(
name='part',
description='description',
IPN='IPN-123'
)
self.assertEqual(parts.count(), 1)
# A new part should *not* have been created
self.assertEqual(Part.objects.count(), n)
# But a different 'revision' *can* be created
response = self.client.post(reverse('api-part-list'), {
'name': 'part',
'description': 'description',
'IPN': 'IPN-123',
'category': 2,
'revision': 'B',
})
self.assertEqual(response.status_code, 201)
self.assertEqual(Part.objects.count(), n + 1)
# Now, check that we cannot *change* an existing part to conflict
pk = response.data['pk']
url = reverse('api-part-detail', kwargs={'pk': pk})
# Attempt to alter the revision code
response = self.client.patch(
url,
{
'revision': 'A',
},
format='json',
)
self.assertEqual(response.status_code, 400)
# But we *can* change it to a unique revision code
response = self.client.patch(
url,
{
'revision': 'C',
}
)
self.assertEqual(response.status_code, 200)
def test_image_upload(self):
"""
Test that we can upload an image to the part API
"""
self.assignRole('part.add')
# Create a new part
response = self.client.post(
reverse('api-part-list'),
{
'name': 'imagine',
'description': 'All the people',
'category': 1,
},
expected_code=201
)
pk = response.data['pk']
url = reverse('api-part-detail', kwargs={'pk': pk})
p = Part.objects.get(pk=pk)
# Part should not have an image!
with self.assertRaises(ValueError):
print(p.image.file)
# Create a custom APIClient for file uploads
# Ref: https://stackoverflow.com/questions/40453947/how-to-generate-a-file-upload-test-request-with-django-rest-frameworks-apireq
upload_client = APIClient()
upload_client.force_authenticate(user=self.user)
# Try to upload a non-image file
with open('dummy_image.txt', 'w') as dummy_image:
dummy_image.write('hello world')
with open('dummy_image.txt', 'rb') as dummy_image:
response = upload_client.patch(
url,
{
'image': dummy_image,
},
format='multipart',
)
self.assertEqual(response.status_code, 400)
# Now try to upload a valid image file
img = PIL.Image.new('RGB', (128, 128), color='red')
img.save('dummy_image.jpg')
with open('dummy_image.jpg', 'rb') as dummy_image:
response = upload_client.patch(
url,
{
'image': dummy_image,
},
format='multipart',
)
self.assertEqual(response.status_code, 200)
# And now check that the image has been set
p = Part.objects.get(pk=pk)
print("Image:", p.image.file)
class PartAPIAggregationTest(InvenTreeAPITestCase): class PartAPIAggregationTest(InvenTreeAPITestCase):
""" """
Tests to ensure that the various aggregation annotations are working correctly... Tests to ensure that the various aggregation annotations are working correctly...
@ -319,6 +571,8 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
# Add a new part # Add a new part
self.part = Part.objects.create( self.part = Part.objects.create(
name='Banana', name='Banana',
description='This is a banana',
category=PartCategory.objects.get(pk=1),
) )
# Create some stock items associated with the part # Create some stock items associated with the part

View File

@ -23,7 +23,7 @@ class TestParams(TestCase):
def test_str(self): def test_str(self):
t1 = PartParameterTemplate.objects.get(pk=1) t1 = PartParameterTemplate.objects.get(pk=1)
self.assertEquals(str(t1), 'Length (mm)') self.assertEqual(str(t1), 'Length (mm)')
p1 = PartParameter.objects.get(pk=1) p1 = PartParameter.objects.get(pk=1)
self.assertEqual(str(p1), 'M2x4 LPHS : Length = 4mm') self.assertEqual(str(p1), 'M2x4 LPHS : Length = 4mm')

View File

@ -11,7 +11,7 @@ from django.core.exceptions import ValidationError
import os import os
from .models import Part, PartTestTemplate from .models import Part, PartCategory, PartTestTemplate
from .models import rename_part_image, match_part_names from .models import rename_part_image, match_part_names
from .templatetags import inventree_extras from .templatetags import inventree_extras
@ -78,6 +78,61 @@ class PartTest(TestCase):
p = Part.objects.get(pk=100) p = Part.objects.get(pk=100)
self.assertEqual(str(p), "BOB | Bob | A2 - Can we build it?") self.assertEqual(str(p), "BOB | Bob | A2 - Can we build it?")
def test_duplicate(self):
"""
Test that we cannot create a "duplicate" Part
"""
n = Part.objects.count()
cat = PartCategory.objects.get(pk=1)
Part.objects.create(
category=cat,
name='part',
description='description',
IPN='IPN',
revision='A',
)
self.assertEqual(Part.objects.count(), n + 1)
part = Part(
category=cat,
name='part',
description='description',
IPN='IPN',
revision='A',
)
with self.assertRaises(ValidationError):
part.validate_unique()
try:
part.save()
self.assertTrue(False)
except:
pass
self.assertEqual(Part.objects.count(), n + 1)
# But we should be able to create a part with a different revision
part_2 = Part.objects.create(
category=cat,
name='part',
description='description',
IPN='IPN',
revision='B',
)
self.assertEqual(Part.objects.count(), n + 2)
# Now, check that we cannot *change* part_2 to conflict
part_2.revision = 'A'
with self.assertRaises(ValidationError):
part_2.validate_unique()
def test_metadata(self): def test_metadata(self):
self.assertEqual(self.r1.name, 'R_2K2_0805') self.assertEqual(self.r1.name, 'R_2K2_0805')
self.assertEqual(self.r1.get_absolute_url(), '/part/3/') self.assertEqual(self.r1.get_absolute_url(), '/part/3/')
@ -277,21 +332,24 @@ class PartSettingsTest(TestCase):
""" """
# Create a part # Create a part
Part.objects.create(name='Hello', description='A thing', IPN='IPN123') Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='A')
# Attempt to create a duplicate item (should fail) # Attempt to create a duplicate item (should fail)
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
Part.objects.create(name='Hello', description='A thing', IPN='IPN123') part = Part(name='Hello', description='A thing', IPN='IPN123', revision='A')
part.validate_unique()
# Attempt to create item with duplicate IPN (should be allowed by default) # Attempt to create item with duplicate IPN (should be allowed by default)
Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='B') Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='B')
# And attempt again with the same values (should fail) # And attempt again with the same values (should fail)
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='B') part = Part(name='Hello', description='A thing', IPN='IPN123', revision='B')
part.validate_unique()
# Now update the settings so duplicate IPN values are *not* allowed # Now update the settings so duplicate IPN values are *not* allowed
InvenTreeSetting.set_setting('PART_ALLOW_DUPLICATE_IPN', False, self.user) InvenTreeSetting.set_setting('PART_ALLOW_DUPLICATE_IPN', False, self.user)
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='C') part = Part(name='Hello', description='A thing', IPN='IPN123', revision='C')
part.full_clean()

View File

@ -981,7 +981,7 @@ class PartPricingView(PartDetail):
part = self.get_part() part = self.get_part()
# Stock history # Stock history
if part.total_stock > 1: if part.total_stock > 1:
ret = [] price_history = []
stock = part.stock_entries(include_variants=False, in_stock=True) # .order_by('purchase_order__date') stock = part.stock_entries(include_variants=False, in_stock=True) # .order_by('purchase_order__date')
stock = stock.prefetch_related('purchase_order', 'supplier_part') stock = stock.prefetch_related('purchase_order', 'supplier_part')
@ -1008,17 +1008,19 @@ class PartPricingView(PartDetail):
line['date'] = stock_item.purchase_order.issue_date.strftime('%d.%m.%Y') line['date'] = stock_item.purchase_order.issue_date.strftime('%d.%m.%Y')
else: else:
line['date'] = stock_item.tracking_info.first().date.strftime('%d.%m.%Y') line['date'] = stock_item.tracking_info.first().date.strftime('%d.%m.%Y')
ret.append(line) price_history.append(line)
ctx['price_history'] = ret ctx['price_history'] = price_history
# BOM Information for Pie-Chart # BOM Information for Pie-Chart
if part.has_bom: if part.has_bom:
# get internal price setting
use_internal = InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False)
ctx_bom_parts = [] ctx_bom_parts = []
# iterate over all bom-items # iterate over all bom-items
for item in part.bom_items.all(): for item in part.bom_items.all():
ctx_item = {'name': str(item.sub_part)} ctx_item = {'name': str(item.sub_part)}
price, qty = item.sub_part.get_price_range(quantity), item.quantity price, qty = item.sub_part.get_price_range(quantity, internal=use_internal), item.quantity
price_min, price_max = 0, 0 price_min, price_max = 0, 0
if price: # check if price available if price: # check if price available

View File

@ -44,6 +44,13 @@ class LocationResource(ModelResource):
StockLocation.objects.rebuild() StockLocation.objects.rebuild()
class LocationInline(admin.TabularInline):
"""
Inline for sub-locations
"""
model = StockLocation
class LocationAdmin(ImportExportModelAdmin): class LocationAdmin(ImportExportModelAdmin):
resource_class = LocationResource resource_class = LocationResource
@ -52,6 +59,10 @@ class LocationAdmin(ImportExportModelAdmin):
search_fields = ('name', 'description') search_fields = ('name', 'description')
inlines = [
LocationInline,
]
class StockItemResource(ModelResource): class StockItemResource(ModelResource):
""" Class for managing StockItem data import/export """ """ Class for managing StockItem data import/export """

View File

@ -199,7 +199,8 @@ def update_history(apps, schema_editor):
update_count += 1 update_count += 1
print(f"\n==========================\nUpdated {update_count} StockItemHistory entries") if update_count > 0:
print(f"\n==========================\nUpdated {update_count} StockItemHistory entries")
def reverse_update(apps, schema_editor): def reverse_update(apps, schema_editor):

View File

@ -0,0 +1,78 @@
# Generated by Django 3.2.4 on 2021-06-21 07:24
from django.db import migrations
def extract_purchase_price(apps, schema_editor):
"""
Find instances of StockItem which do *not* have a purchase price set,
but which point to a PurchaseOrder where there *is* a purchase price set.
Then, assign *that* purchase price to original StockItem.
This is to address an issue where older versions of InvenTree
did not correctly copy purchase price information cross to the StockItem objects.
Current InvenTree version (as of 2021-06-21) copy this information across correctly,
so this one-time data migration should suffice.
"""
# Required database models
StockItem = apps.get_model('stock', 'stockitem')
PurchaseOrder = apps.get_model('order', 'purchaseorder')
PurchaseOrderLineItem = apps.get_model('order', 'purchaseorderlineitem')
Part = apps.get_model('part', 'part')
# Find all the StockItem objects without a purchase_price which point to a PurchaseOrder
items = StockItem.objects.filter(purchase_price=None).exclude(purchase_order=None)
if items.count() > 0:
print(f"Found {items.count()} stock items with missing purchase price information")
update_count = 0
for item in items:
part_id = item.part
po = item.purchase_order
# Look for a matching PurchaseOrderLineItem (with a price)
lines = PurchaseOrderLineItem.objects.filter(part__part=part_id, order=po)
if lines.exists():
for line in lines:
if line.purchase_price is not None:
# Copy pricing information across
item.purchase_price = line.purchase_price
item.purchases_price_currency = line.purchase_price_currency
print(f"- Updating supplier price for {item.part.name} - {item.purchase_price} {item.purchase_price_currency}")
update_count += 1
item.save()
break
if update_count > 0:
print(f"Updated pricing for {update_count} stock items")
def reverse_operation(apps, schema_editor):
"""
DO NOTHING!
"""
pass
class Migration(migrations.Migration):
dependencies = [
('stock', '0063_auto_20210511_2343'),
]
operations = [
migrations.RunPython(extract_purchase_price, reverse_code=reverse_operation)
]

View File

@ -81,6 +81,7 @@ class StockItemSerializer(InvenTreeModelSerializer):
'belongs_to', 'belongs_to',
'build', 'build',
'customer', 'customer',
'purchase_order',
'sales_order', 'sales_order',
'supplier_part', 'supplier_part',
'supplier_part__supplier', 'supplier_part__supplier',
@ -163,6 +164,10 @@ class StockItemSerializer(InvenTreeModelSerializer):
purchase_price = serializers.SerializerMethodField() purchase_price = serializers.SerializerMethodField()
purchase_order_reference = serializers.CharField(source='purchase_order.reference', read_only=True)
sales_order_reference = serializers.CharField(source='sales_order.reference', read_only=True)
def get_purchase_price(self, obj): def get_purchase_price(self, obj):
""" Return purchase_price (Money field) as string (includes currency) """ """ Return purchase_price (Money field) as string (includes currency) """
@ -208,10 +213,13 @@ class StockItemSerializer(InvenTreeModelSerializer):
'packaging', 'packaging',
'part', 'part',
'part_detail', 'part_detail',
'purchase_order',
'purchase_order_reference',
'pk', 'pk',
'quantity', 'quantity',
'required_tests', 'required_tests',
'sales_order', 'sales_order',
'sales_order_reference',
'serial', 'serial',
'stale', 'stale',
'status', 'status',

View File

@ -325,7 +325,7 @@
<td><a href="{% url 'po-detail' item.purchase_order.id %}">{{ item.purchase_order }}</a></td> <td><a href="{% url 'po-detail' item.purchase_order.id %}">{{ item.purchase_order }}</a></td>
</tr> </tr>
{% endif %} {% endif %}
{% if item.purchase_price %} {% if item.purchase_price != None %}
<tr> <tr>
<td><span class='fas fa-dollar-sign'></span></td> <td><span class='fas fa-dollar-sign'></span></td>
<td>{% trans "Purchase Price" %}</td> <td>{% trans "Purchase Price" %}</td>
@ -350,7 +350,12 @@
<tr> <tr>
<td><span class='fas fa-industry'></span></td> <td><span class='fas fa-industry'></span></td>
<td>{% trans "Manufacturer" %}</td> <td>{% trans "Manufacturer" %}</td>
<td><a href="{% url 'company-detail' item.supplier_part.manufacturer_part.manufacturer.id %}">{{ item.supplier_part.manufacturer_part.manufacturer.name }}</a></td> {% if item.supplier_part.manufacturer_part.manufacturer %}
<td><a href="{% url 'company-detail' item.supplier_part.manufacturer_part.manufacturer.id %}">{{ item.supplier_part.manufacturer_part.manufacturer.name }}</a></td>
{% else %}
<td><i>{% trans "No manufacturer set" %}</i></td>
{% endif %}
</tr> </tr>
<tr> <tr>
<td><span class='fas fa-hashtag'></span></td> <td><span class='fas fa-hashtag'></span></td>

View File

@ -20,6 +20,7 @@
{% include "InvenTree/settings/setting.html" with key="PART_ALLOW_DUPLICATE_IPN" %} {% include "InvenTree/settings/setting.html" with key="PART_ALLOW_DUPLICATE_IPN" %}
{% include "InvenTree/settings/setting.html" with key="PART_ALLOW_EDIT_IPN" %} {% include "InvenTree/settings/setting.html" with key="PART_ALLOW_EDIT_IPN" %}
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_QUANTITY_IN_FORMS" icon="fa-hashtag" %} {% include "InvenTree/settings/setting.html" with key="PART_SHOW_QUANTITY_IN_FORMS" icon="fa-hashtag" %}
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_PRICE_IN_FORMS" icon="fa-dollar-sign" %}
{% include "InvenTree/settings/setting.html" with key="PART_RECENT_COUNT" icon="fa-clock" %} {% include "InvenTree/settings/setting.html" with key="PART_RECENT_COUNT" icon="fa-clock" %}
<tr><td colspan='5 '></td></tr> <tr><td colspan='5 '></td></tr>
{% include "InvenTree/settings/setting.html" with key="PART_TEMPLATE" icon="fa-clone" %} {% include "InvenTree/settings/setting.html" with key="PART_TEMPLATE" icon="fa-clone" %}

View File

@ -34,6 +34,11 @@
<td>{% trans "API Version" %}</td> <td>{% trans "API Version" %}</td>
<td>{% inventree_api_version %}{% include "clip.html" %}</td> <td>{% inventree_api_version %}{% include "clip.html" %}</td>
</tr> </tr>
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Python Version" %}</td>
<td>{% python_version %}</td>
</tr>
<tr> <tr>
<td><span class='fas fa-hashtag'></span></td> <td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Django Version" %}</td> <td>{% trans "Django Version" %}</td>

View File

@ -418,6 +418,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
sub_part_detail: true, sub_part_detail: true,
sub_part_trackable: trackable, sub_part_trackable: trackable,
}, },
disablePagination: true,
formatNoMatches: function() { formatNoMatches: function() {
return '{% trans "No BOM items found" %}'; return '{% trans "No BOM items found" %}';
}, },
@ -667,6 +668,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
{ {
field: 'sub_part_detail.stock', field: 'sub_part_detail.stock',
title: '{% trans "Available" %}', title: '{% trans "Available" %}',
sortable: true,
}, },
{ {
field: 'allocated', field: 'allocated',
@ -686,14 +688,13 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
return makeProgressBar(allocated, required); return makeProgressBar(allocated, required);
}, },
sorter: function(valA, valB, rowA, rowB) { sorter: function(valA, valB, rowA, rowB) {
// Custom sorting function for progress bars
var aA = sumAllocations(rowA); var aA = sumAllocations(rowA);
var aB = sumAllocations(rowB); var aB = sumAllocations(rowB);
var qA = rowA.quantity; var qA = requiredQuantity(rowA);
var qB = rowB.quantity; var qB = requiredQuantity(rowB);
qA *= output.quantity;
qB *= output.quantity;
// Handle the case where both numerators are zero // Handle the case where both numerators are zero
if ((aA == 0) && (aB == 0)) { if ((aA == 0) && (aB == 0)) {
@ -713,6 +714,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
return (qA < qB) ? 1 : -1; return (qA < qB) ? 1 : -1;
} }
if (progressA == progressB) return 0;
return (progressA < progressB) ? 1 : -1; return (progressA < progressB) ? 1 : -1;
} }
}, },

View File

@ -126,7 +126,7 @@ function loadManufacturerPartTable(table, url, options) {
queryParams: filters, queryParams: filters,
name: 'manufacturerparts', name: 'manufacturerparts',
groupBy: false, groupBy: false,
formatNoMatches: function() { return "{% trans "No manufacturer parts found" %}"; }, formatNoMatches: function() { return '{% trans "No manufacturer parts found" %}'; },
columns: [ columns: [
{ {
checkbox: true, checkbox: true,
@ -199,6 +199,107 @@ function loadManufacturerPartTable(table, url, options) {
} }
function loadManufacturerPartParameterTable(table, url, options) {
/*
* Load table of ManufacturerPartParameter objects
*/
var params = options.params || {};
// Load filters
var filters = loadTableFilters("manufacturer-part-parameters");
// Overwrite explicit parameters
for (var key in params) {
filters[key] = params[key];
}
// setupFilterList("manufacturer-part-parameters", $(table));
$(table).inventreeTable({
url: url,
method: 'get',
original: params,
queryParams: filters,
name: 'manufacturerpartparameters',
groupBy: false,
formatNoMatches: function() { return '{% trans "No parameters found" %}'; },
columns: [
{
checkbox: true,
switchable: false,
visible: false,
},
{
field: 'name',
title: '{% trans "Name" %}',
switchable: false,
sortable: true,
},
{
field: 'value',
title: '{% trans "Value" %}',
switchable: false,
sortable: true,
},
{
field: 'units',
title: '{% trans "Units" %}',
switchable: true,
sortable: true,
},
{
field: 'actions',
title: '',
switchable: false,
sortable: false,
formatter: function(value, row) {
var pk = row.pk;
var html = `<div class='btn-group float-right' role='group'>`;
html += makeIconButton('fa-edit icon-blue', 'button-parameter-edit', pk, '{% trans "Edit parameter" %}');
html += makeIconButton('fa-trash-alt icon-red', 'button-parameter-delete', pk, '{% trans "Delete parameter" %}');
html += `</div>`;
return html;
}
}
],
onPostBody: function() {
// Setup callback functions
$(table).find('.button-parameter-edit').click(function() {
var pk = $(this).attr('pk');
launchModalForm(
`/manufacturer-part/parameter/${pk}/edit/`,
{
success: function() {
$(table).bootstrapTable('refresh');
}
}
);
});
$(table).find('.button-parameter-delete').click(function() {
var pk = $(this).attr('pk');
launchModalForm(
`/manufacturer-part/parameter/${pk}/delete/`,
{
success: function() {
$(table).bootstrapTable('refresh');
}
}
);
});
}
});
}
function loadSupplierPartTable(table, url, options) { function loadSupplierPartTable(table, url, options) {
/* /*
* Load supplier part table * Load supplier part table
@ -224,7 +325,7 @@ function loadSupplierPartTable(table, url, options) {
queryParams: filters, queryParams: filters,
name: 'supplierparts', name: 'supplierparts',
groupBy: false, groupBy: false,
formatNoMatches: function() { return "{% trans "No supplier parts found" %}"; }, formatNoMatches: function() { return '{% trans "No supplier parts found" %}'; },
columns: [ columns: [
{ {
checkbox: true, checkbox: true,
@ -260,7 +361,7 @@ function loadSupplierPartTable(table, url, options) {
{ {
sortable: true, sortable: true,
field: 'supplier', field: 'supplier',
title: "{% trans "Supplier" %}", title: '{% trans "Supplier" %}',
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {
if (value) { if (value) {
var name = row.supplier_detail.name; var name = row.supplier_detail.name;
@ -276,7 +377,7 @@ function loadSupplierPartTable(table, url, options) {
{ {
sortable: true, sortable: true,
field: 'SKU', field: 'SKU',
title: "{% trans "Supplier Part" %}", title: '{% trans "Supplier Part" %}',
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {
return renderLink(value, `/supplier-part/${row.pk}/`); return renderLink(value, `/supplier-part/${row.pk}/`);
} }

View File

@ -447,6 +447,7 @@ function loadPartTable(table, url, options={}) {
columns.push({ columns.push({
sortable: true, sortable: true,
sortName: 'category',
field: 'category_detail', field: 'category_detail',
title: '{% trans "Category" %}', title: '{% trans "Category" %}',
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {

View File

@ -179,27 +179,32 @@ function loadStockTestResultsTable(table, options) {
var match = false; var match = false;
var override = false; var override = false;
// Extract the simplified test key
var key = item.key; var key = item.key;
// Attempt to associate this result with an existing test // Attempt to associate this result with an existing test
tableData.forEach(function(row, index) { for (var idx = 0; idx < tableData.length; idx++) {
var row = tableData[idx];
if (key == row.key) { if (key == row.key) {
item.test_name = row.test_name; item.test_name = row.test_name;
item.required = row.required; item.required = row.required;
match = true;
if (row.result == null) { if (row.result == null) {
item.parent = parent_node; item.parent = parent_node;
tableData[index] = item; tableData[idx] = item;
override = true; override = true;
} else { } else {
item.parent = row.pk; item.parent = row.pk;
} }
match = true;
break;
} }
}); }
// No match could be found // No match could be found
if (!match) { if (!match) {
@ -603,7 +608,6 @@ function loadStockTable(table, options) {
// REJECTED // REJECTED
if (row.status == {{ StockStatus.REJECTED }}) { if (row.status == {{ StockStatus.REJECTED }}) {
console.log("REJECTED - {{ StockStatus.REJECTED }}");
html += makeIconBadge('fa-times-circle icon-red', '{% trans "Stock item has been rejected" %}'); html += makeIconBadge('fa-times-circle icon-red', '{% trans "Stock item has been rejected" %}');
} }
// LOST // LOST
@ -660,6 +664,27 @@ function loadStockTable(table, options) {
title: '{% trans "Last Updated" %}', title: '{% trans "Last Updated" %}',
sortable: true, sortable: true,
}, },
{
field: 'purchase_order',
title: '{% trans "Purchase Order" %}',
formatter: function(value, row) {
if (!value) {
return '-';
}
var link = `/order/purchase-order/${row.purchase_order}/`;
var text = `${row.purchase_order}`;
if (row.purchase_order_reference) {
var prefix = '{% settings_value "PURCHASEORDER_REFERENCE_PREFIX" %}';
text = prefix + row.purchase_order_reference;
}
return renderLink(text, link);
}
},
{ {
field: 'purchase_price', field: 'purchase_price',
title: '{% trans "Purchase Price" %}', title: '{% trans "Purchase Price" %}',

View File

@ -134,12 +134,14 @@ $.fn.inventreeTable = function(options) {
var varName = tableName + '-pagesize'; var varName = tableName + '-pagesize';
// Pagingation options (can be server-side or client-side as specified by the caller) // Pagingation options (can be server-side or client-side as specified by the caller)
options.pagination = true; if (!options.disablePagination) {
options.paginationVAlign = options.paginationVAlign || 'both'; options.pagination = true;
options.pageSize = inventreeLoad(varName, 25); options.paginationVAlign = options.paginationVAlign || 'both';
options.pageList = [25, 50, 100, 250, 'all']; options.pageSize = inventreeLoad(varName, 25);
options.totalField = 'count'; options.pageList = [25, 50, 100, 250, 'all'];
options.dataField = 'results'; options.totalField = 'count';
options.dataField = 'results';
}
// Extract query params // Extract query params
var filters = options.queryParams || options.filters || {}; var filters = options.queryParams || options.filters || {};

View File

@ -43,6 +43,9 @@
</div> </div>
{% endblock %} {% endblock %}
{% block pre_content_panels %}
{% endblock %}
{% block content_panels %} {% block content_panels %}
<div class='panel panel-default panel-inventree'> <div class='panel panel-default panel-inventree'>
<div class='panel-heading'> <div class='panel-heading'>
@ -63,6 +66,9 @@
</div> </div>
{% endblock %} {% endblock %}
{% block post_content_panels %}
{% endblock %}
{% endblock %} {% endblock %}
{% block js_ready %} {% block js_ready %}

View File

@ -13,7 +13,7 @@ class UsersConfig(AppConfig):
def ready(self): def ready(self):
if canAppAccessDatabase(): if canAppAccessDatabase(allow_test=True):
try: try:
self.assign_permissions() self.assign_permissions()

View File

@ -85,6 +85,7 @@ class RuleSet(models.Model):
'part_partstar', 'part_partstar',
'company_supplierpart', 'company_supplierpart',
'company_manufacturerpart', 'company_manufacturerpart',
'company_manufacturerpartparameter',
], ],
'stock_location': [ 'stock_location': [
'stock_stocklocation', 'stock_stocklocation',
@ -116,6 +117,8 @@ class RuleSet(models.Model):
'order_purchaseorderattachment', 'order_purchaseorderattachment',
'order_purchaseorderlineitem', 'order_purchaseorderlineitem',
'company_supplierpart', 'company_supplierpart',
'company_manufacturerpart',
'company_manufacturerpartparameter',
], ],
'sales_order': [ 'sales_order': [
'company_company', 'company_company',
@ -273,7 +276,7 @@ def update_group_roles(group, debug=False):
""" """
if not canAppAccessDatabase(): if not canAppAccessDatabase(allow_test=True):
return return
# List of permissions already associated with this group # List of permissions already associated with this group

23
RELEASE.md Normal file
View File

@ -0,0 +1,23 @@
## Release Checklist
Checklist of steps to perform at each code release
### Update Version String
Update `INVENTREE_SW_VERSION` in [version.py](https://github.com/inventree/InvenTree/blob/master/InvenTree/InvenTree/version.py)
### Increment API Version
If the API has changed, ensure that the API version number is incremented.
### Translation Files
Merge the crowdin translation updates into master branch
### Documentation Release
Create new release for the [inventree documentation](https://github.com/inventree/inventree-docs)
### Python Library Release
Create new release for the [inventree python library](https://github.com/inventree/inventree-python)

View File

@ -251,12 +251,15 @@ def content_excludes():
"contenttypes", "contenttypes",
"sessions.session", "sessions.session",
"auth.permission", "auth.permission",
"authtoken.token",
"error_report.error", "error_report.error",
"admin.logentry", "admin.logentry",
"django_q.schedule", "django_q.schedule",
"django_q.task", "django_q.task",
"django_q.ormq", "django_q.ormq",
"users.owner", "users.owner",
"exchange.rate",
"exchange.exchangebackend",
] ]
output = "" output = ""
@ -362,6 +365,21 @@ def import_records(c, filename='data.json'):
print("Data import completed") print("Data import completed")
@task
def delete_data(c, force=False):
"""
Delete all database records!
Warning: This will REALLY delete all records in the database!!
"""
if force:
manage(c, 'flush --noinput')
else:
manage(c, 'flush')
@task(post=[rebuild]) @task(post=[rebuild])
def import_fixtures(c): def import_fixtures(c):
""" """