mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'master' into part-import
This commit is contained in:
commit
e760aa172a
49
.github/workflows/python.yaml
vendored
Normal file
49
.github/workflows/python.yaml
vendored
Normal 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
3
.gitignore
vendored
@ -35,6 +35,9 @@ local_settings.py
|
||||
*.backup
|
||||
*.old
|
||||
|
||||
# Files used for testing
|
||||
dummy_image.*
|
||||
|
||||
# Sphinx files
|
||||
docs/_build
|
||||
|
||||
|
@ -73,22 +73,50 @@ class InvenTreeAPITestCase(APITestCase):
|
||||
ruleset.save()
|
||||
break
|
||||
|
||||
def get(self, url, data={}, code=200):
|
||||
def get(self, url, data={}, expected_code=200):
|
||||
"""
|
||||
Issue a GET request
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
def post(self, url, data):
|
||||
def post(self, url, data, expected_code=None):
|
||||
"""
|
||||
Issue a POST request
|
||||
"""
|
||||
|
||||
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
|
||||
|
@ -12,7 +12,7 @@ def isInTestMode():
|
||||
return False
|
||||
|
||||
|
||||
def canAppAccessDatabase():
|
||||
def canAppAccessDatabase(allow_test=False):
|
||||
"""
|
||||
Returns True if the apps.py file can access database records.
|
||||
|
||||
@ -33,11 +33,16 @@ def canAppAccessDatabase():
|
||||
'createsuperuser',
|
||||
'wait_for_db',
|
||||
'prerender',
|
||||
'rebuild',
|
||||
'collectstatic',
|
||||
'makemessages',
|
||||
'compilemessages',
|
||||
]
|
||||
|
||||
if not allow_test:
|
||||
# Override for testing mode?
|
||||
excluded_commands.append('test')
|
||||
|
||||
for cmd in excluded_commands:
|
||||
if cmd in sys.argv:
|
||||
return False
|
||||
|
@ -6,12 +6,15 @@ Serializers used in various InvenTree apps
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
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):
|
||||
@ -39,18 +42,34 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
|
||||
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.
|
||||
In addition to running validators on the serializer fields,
|
||||
this class ensures that the underlying model is also validated.
|
||||
"""
|
||||
|
||||
# Run any native validation checks first (may throw an ValidationError)
|
||||
data = super(serializers.ModelSerializer, self).validate(data)
|
||||
# Run any native validation checks first (may raise a ValidationError)
|
||||
data = super().run_validation(data)
|
||||
|
||||
# Now ensure the underlying model is correct
|
||||
|
||||
if not hasattr(self, 'instance') or self.instance is None:
|
||||
# No instance exists (we are creating a new one)
|
||||
instance = self.Meta.model(**data)
|
||||
instance.clean()
|
||||
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
|
||||
|
||||
@ -82,3 +101,17 @@ class InvenTreeAttachmentSerializerField(serializers.FileField):
|
||||
return None
|
||||
|
||||
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))
|
||||
|
@ -58,7 +58,7 @@ function inventreeFormDataUpload(url, data, options={}) {
|
||||
xhr.setRequestHeader('X-CSRFToken', csrftoken);
|
||||
},
|
||||
url: url,
|
||||
method: 'POST',
|
||||
method: options.method || 'POST',
|
||||
data: data,
|
||||
processData: false,
|
||||
contentType: false,
|
||||
|
@ -219,6 +219,7 @@ function enableDragAndDrop(element, url, options) {
|
||||
data - Other form data to upload
|
||||
success - Callback function in case of success
|
||||
error - Callback function in case of error
|
||||
method - HTTP method
|
||||
*/
|
||||
|
||||
data = options.data || {};
|
||||
@ -254,7 +255,8 @@ function enableDragAndDrop(element, url, options) {
|
||||
if (options.error) {
|
||||
options.error(xhr, status, error);
|
||||
}
|
||||
}
|
||||
},
|
||||
method: options.method || 'POST',
|
||||
}
|
||||
);
|
||||
} else {
|
||||
|
@ -4,9 +4,10 @@ Provides system status functionality checks.
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils import timezone
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
|
||||
from django_q.models import Success
|
||||
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
|
||||
"""
|
||||
|
||||
now = datetime.now()
|
||||
now = timezone.now()
|
||||
past = now - timedelta(minutes=20)
|
||||
|
||||
results = Success.objects.filter(
|
||||
@ -60,21 +61,21 @@ def is_email_configured():
|
||||
|
||||
# Display warning unless in test mode
|
||||
if not settings.TESTING:
|
||||
logger.warning("EMAIL_HOST is not configured")
|
||||
logger.debug("EMAIL_HOST is not configured")
|
||||
|
||||
if not settings.EMAIL_HOST_USER:
|
||||
configured = False
|
||||
|
||||
# Display warning unless in test mode
|
||||
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:
|
||||
configured = False
|
||||
|
||||
# Display warning unless in test mode
|
||||
if not settings.TESTING:
|
||||
logger.warning("EMAIL_HOST_PASSWORD is not configured")
|
||||
logger.debug("EMAIL_HOST_PASSWORD is not configured")
|
||||
|
||||
return configured
|
||||
|
||||
|
@ -28,7 +28,7 @@ def schedule_task(taskname, **kwargs):
|
||||
try:
|
||||
from django_q.models import Schedule
|
||||
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
|
||||
|
||||
try:
|
||||
@ -80,7 +80,7 @@ def heartbeat():
|
||||
|
||||
try:
|
||||
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:
|
||||
return
|
||||
|
||||
@ -105,7 +105,7 @@ def delete_successful_tasks():
|
||||
try:
|
||||
from django_q.models import Success
|
||||
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
|
||||
|
||||
threshold = datetime.now() - timedelta(days=30)
|
||||
@ -126,6 +126,7 @@ def check_for_updates():
|
||||
import common.models
|
||||
except AppRegistryNotReady:
|
||||
# Apps not yet loaded!
|
||||
logger.info("Could not perform 'check_for_updates' - App registry not ready")
|
||||
return
|
||||
|
||||
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
|
||||
except AppRegistryNotReady:
|
||||
# Apps not yet loaded!
|
||||
logger.info("Could not perform 'update_exchange_rates' - App registry not ready")
|
||||
return
|
||||
except:
|
||||
# Other error?
|
||||
|
@ -77,7 +77,7 @@ class APITests(InvenTreeAPITestCase):
|
||||
self.assertIn('version', data)
|
||||
self.assertIn('instance', data)
|
||||
|
||||
self.assertEquals('InvenTree', data['server'])
|
||||
self.assertEqual('InvenTree', data['server'])
|
||||
|
||||
def test_role_view(self):
|
||||
"""
|
||||
|
@ -10,19 +10,25 @@ import common.models
|
||||
|
||||
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
|
||||
|
||||
v3 -> 2021-05-22:
|
||||
- The updated StockItem "history tracking" now uses a different interface
|
||||
v6 -> 2021-06-23
|
||||
- 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
|
||||
- BOM items can now accept "variant stock" to be assigned against them
|
||||
- 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():
|
||||
|
@ -337,7 +337,7 @@ class AjaxMixin(InvenTreeRoleMixin):
|
||||
# Do nothing by default
|
||||
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.
|
||||
|
||||
Args:
|
||||
@ -349,6 +349,9 @@ class AjaxMixin(InvenTreeRoleMixin):
|
||||
Returns:
|
||||
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():
|
||||
return HttpResponseRedirect('/')
|
||||
|
@ -40,6 +40,7 @@ def assign_bom_items(apps, schema_editor):
|
||||
except BomItem.DoesNotExist:
|
||||
pass
|
||||
|
||||
if count_total > 0:
|
||||
print(f"Assigned BomItem for {count_valid}/{count_total} entries")
|
||||
|
||||
|
||||
|
@ -212,6 +212,13 @@ class InvenTreeSetting(models.Model):
|
||||
'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': {
|
||||
'name': _('Internal Prices'),
|
||||
'description': _('Enable internal prices for parts'),
|
||||
|
@ -11,6 +11,7 @@ import import_export.widgets as widgets
|
||||
from .models import Company
|
||||
from .models import SupplierPart
|
||||
from .models import SupplierPriceBreak
|
||||
from .models import ManufacturerPart, ManufacturerPartParameter
|
||||
|
||||
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 for managing SupplierPriceBreak data import/export """
|
||||
|
||||
@ -103,3 +190,6 @@ class SupplierPriceBreakAdmin(ImportExportModelAdmin):
|
||||
admin.site.register(Company, CompanyAdmin)
|
||||
admin.site.register(SupplierPart, SupplierPartAdmin)
|
||||
admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin)
|
||||
|
||||
admin.site.register(ManufacturerPart, ManufacturerPartAdmin)
|
||||
admin.site.register(ManufacturerPartParameter, ManufacturerPartParameterAdmin)
|
||||
|
@ -15,11 +15,11 @@ from django.db.models import Q
|
||||
from InvenTree.helpers import str2bool
|
||||
|
||||
from .models import Company
|
||||
from .models import ManufacturerPart
|
||||
from .models import ManufacturerPart, ManufacturerPartParameter
|
||||
from .models import SupplierPart, SupplierPriceBreak
|
||||
|
||||
from .serializers import CompanySerializer
|
||||
from .serializers import ManufacturerPartSerializer
|
||||
from .serializers import ManufacturerPartSerializer, ManufacturerPartParameterSerializer
|
||||
from .serializers import SupplierPartSerializer, SupplierPriceBreakSerializer
|
||||
|
||||
|
||||
@ -175,6 +175,86 @@ class ManufacturerPartDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
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):
|
||||
""" API endpoint for list view of SupplierPart object
|
||||
|
||||
@ -249,7 +329,7 @@ class SupplierPartList(generics.ListCreateAPIView):
|
||||
params = self.request.query_params
|
||||
kwargs['part_detail'] = str2bool(params.get('part_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))
|
||||
except AttributeError:
|
||||
pass
|
||||
@ -316,6 +396,13 @@ class SupplierPriceBreakList(generics.ListCreateAPIView):
|
||||
|
||||
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'),
|
||||
|
||||
# Catch anything else
|
||||
|
@ -44,8 +44,6 @@ class CompanyConfig(AppConfig):
|
||||
company.image.render_variations(replace=False)
|
||||
except FileNotFoundError:
|
||||
logger.warning(f"Image file '{company.image}' missing")
|
||||
company.image = None
|
||||
company.save()
|
||||
except UnidentifiedImageError:
|
||||
logger.warning(f"Image file '{company.image}' is invalid")
|
||||
except (OperationalError, ProgrammingError):
|
||||
|
@ -16,7 +16,7 @@ from djmoney.forms.fields import MoneyField
|
||||
|
||||
from common.settings import currency_code_default
|
||||
|
||||
from .models import Company
|
||||
from .models import Company, ManufacturerPartParameter
|
||||
from .models import ManufacturerPart
|
||||
from .models import SupplierPart
|
||||
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):
|
||||
""" Form for editing a SupplierPart object """
|
||||
|
||||
|
@ -71,6 +71,7 @@ def migrate_currencies(apps, schema_editor):
|
||||
|
||||
count += 1
|
||||
|
||||
if count > 0:
|
||||
print(f"Updated {count} SupplierPriceBreak rows")
|
||||
|
||||
def reverse_currencies(apps, schema_editor):
|
||||
|
@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
@ -371,6 +371,47 @@ class ManufacturerPart(models.Model):
|
||||
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):
|
||||
""" Represents a unique part as provided by a Supplier
|
||||
Each SupplierPart is identified by a SKU (Supplier Part Number)
|
||||
|
@ -6,14 +6,15 @@ from rest_framework import serializers
|
||||
|
||||
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 InvenTreeImageSerializerField
|
||||
|
||||
from part.serializers import PartBriefSerializer
|
||||
|
||||
from .models import Company
|
||||
from .models import ManufacturerPart, ManufacturerPartParameter
|
||||
from .models import SupplierPart, SupplierPriceBreak
|
||||
|
||||
|
||||
class CompanyBriefSerializer(InvenTreeModelSerializer):
|
||||
""" Serializer for Company object (limited detail) """
|
||||
@ -52,7 +53,7 @@ class CompanySerializer(InvenTreeModelSerializer):
|
||||
|
||||
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_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):
|
||||
""" Serializer for SupplierPart object """
|
||||
|
||||
|
@ -139,13 +139,19 @@
|
||||
|
||||
enableDragAndDrop(
|
||||
"#company-thumb",
|
||||
"{% url 'company-image' company.id %}",
|
||||
"{% url 'api-company-detail' company.id %}",
|
||||
{
|
||||
label: 'image',
|
||||
method: 'PATCH',
|
||||
success: function(data, status, xhr) {
|
||||
|
||||
if (data.image) {
|
||||
$('#company-image').attr('src', data.image);
|
||||
} else {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
{% if company.image %}
|
||||
|
@ -7,7 +7,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Supplier Parts" %}
|
||||
{% trans "Suppliers" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block details %}
|
||||
@ -30,9 +30,44 @@
|
||||
|
||||
{% 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.super }}
|
||||
|
||||
$('#parameter-create').click(function() {
|
||||
launchModalForm(
|
||||
"{% url 'manufacturer-part-parameter-create' %}",
|
||||
{
|
||||
data: {
|
||||
manufacturer_part: {{ part.id }},
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$('#supplier-create').click(function () {
|
||||
launchModalForm(
|
||||
"{% 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'])
|
||||
|
||||
{% endblock %}
|
@ -50,10 +50,15 @@ class CompanyTest(InvenTreeAPITestCase):
|
||||
self.assertEqual(response.data['name'], 'ACME')
|
||||
|
||||
# Change the name of the company
|
||||
# Note we should not have the correct permissions (yet)
|
||||
data = response.data
|
||||
data['name'] = 'ACMOO'
|
||||
response = self.client.patch(url, data, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
response = self.client.patch(url, data, format='json', expected_code=400)
|
||||
|
||||
self.assignRole('company.change')
|
||||
|
||||
response = self.client.patch(url, data, format='json', expected_code=200)
|
||||
|
||||
self.assertEqual(response.data['name'], 'ACMOO')
|
||||
|
||||
def test_company_search(self):
|
||||
@ -119,7 +124,9 @@ class ManufacturerTest(InvenTreeAPITestCase):
|
||||
data = {
|
||||
'MPN': 'MPN-TEST-123',
|
||||
}
|
||||
|
||||
response = self.client.patch(url, data, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['MPN'], 'MPN-TEST-123')
|
||||
|
||||
|
@ -53,20 +53,25 @@ price_break_urls = [
|
||||
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 = [
|
||||
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 = [
|
||||
|
@ -23,14 +23,14 @@ from InvenTree.views import AjaxCreateView, AjaxUpdateView, AjaxDeleteView
|
||||
from InvenTree.helpers import str2bool
|
||||
from InvenTree.views import InvenTreeRoleMixin
|
||||
|
||||
from .models import Company
|
||||
from .models import Company, ManufacturerPartParameter
|
||||
from .models import ManufacturerPart
|
||||
from .models import SupplierPart
|
||||
from .models import SupplierPriceBreak
|
||||
|
||||
from part.models import Part
|
||||
|
||||
from .forms import EditCompanyForm
|
||||
from .forms import EditCompanyForm, EditManufacturerPartParameterForm
|
||||
from .forms import CompanyImageForm
|
||||
from .forms import EditManufacturerPartForm
|
||||
from .forms import EditSupplierPartForm
|
||||
@ -504,6 +504,66 @@ class ManufacturerPartDelete(AjaxDeleteView):
|
||||
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):
|
||||
""" Detail view for SupplierPart """
|
||||
model = SupplierPart
|
||||
@ -563,6 +623,7 @@ class SupplierPartEdit(AjaxUpdateView):
|
||||
supplier_part = self.get_object()
|
||||
|
||||
if supplier_part.manufacturer_part:
|
||||
if supplier_part.manufacturer_part.manufacturer:
|
||||
initials['manufacturer'] = supplier_part.manufacturer_part.manufacturer.id
|
||||
initials['MPN'] = supplier_part.manufacturer_part.MPN
|
||||
|
||||
|
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
@ -157,7 +157,7 @@ class POList(generics.ListCreateAPIView):
|
||||
ordering = '-creation_date'
|
||||
|
||||
|
||||
class PODetail(generics.RetrieveUpdateAPIView):
|
||||
class PODetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
""" API endpoint for detail view of a PurchaseOrder object """
|
||||
|
||||
queryset = PurchaseOrder.objects.all()
|
||||
@ -382,7 +382,7 @@ class SOList(generics.ListCreateAPIView):
|
||||
ordering = '-creation_date'
|
||||
|
||||
|
||||
class SODetail(generics.RetrieveUpdateAPIView):
|
||||
class SODetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""
|
||||
API endpoint for detail view of a SalesOrder object.
|
||||
"""
|
||||
|
@ -92,8 +92,10 @@ class POSerializer(InvenTreeModelSerializer):
|
||||
]
|
||||
|
||||
read_only_fields = [
|
||||
'reference',
|
||||
'status'
|
||||
'issue_date',
|
||||
'complete_date',
|
||||
'creation_date',
|
||||
]
|
||||
|
||||
|
||||
@ -109,8 +111,9 @@ class POLineItemSerializer(InvenTreeModelSerializer):
|
||||
self.fields.pop('part_detail')
|
||||
self.fields.pop('supplier_part_detail')
|
||||
|
||||
quantity = serializers.FloatField()
|
||||
received = serializers.FloatField()
|
||||
# TODO: Once https://github.com/inventree/InvenTree/issues/1687 is fixed, remove default values
|
||||
quantity = serializers.FloatField(default=1)
|
||||
received = serializers.FloatField(default=0)
|
||||
|
||||
part_detail = PartBriefSerializer(source='get_base_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 = [
|
||||
'reference',
|
||||
'status'
|
||||
'status',
|
||||
'creation_date',
|
||||
'shipment_date',
|
||||
]
|
||||
|
||||
|
||||
@ -312,7 +316,9 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
|
||||
part_detail = PartBriefSerializer(source='part', many=False, 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)
|
||||
fulfilled = serializers.FloatField(source='fulfilled_quantity', read_only=True)
|
||||
sale_price_string = serializers.CharField(source='sale_price', read_only=True)
|
||||
|
@ -75,7 +75,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<tr>
|
||||
<td><span class='fas fa-hashtag'></span></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>
|
||||
<td><span class='fas fa-info'></span></td>
|
||||
|
@ -4,6 +4,8 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% block form %}
|
||||
{% default_currency as currency %}
|
||||
{% settings_value 'PART_SHOW_PRICE_IN_FORMS' as show_price %}
|
||||
|
||||
<h4>
|
||||
{% 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 }}">
|
||||
<option value=''>---------</option>
|
||||
{% 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 %}
|
||||
</select>
|
||||
</div>
|
||||
|
@ -57,8 +57,6 @@ $("#attachment-table").on('click', '.attachment-delete-button', function() {
|
||||
|
||||
var url = `/order/purchase-order/attachment/${button.attr('pk')}/delete/`;
|
||||
|
||||
console.log("url: " + url);
|
||||
|
||||
launchModalForm(url, {
|
||||
reload: true,
|
||||
});
|
||||
|
@ -77,7 +77,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<tr>
|
||||
<td><span class='fas fa-hashtag'></span></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>
|
||||
<td><span class='fas fa-info'></span></td>
|
||||
|
@ -110,6 +110,96 @@ class PurchaseOrderTest(OrderTest):
|
||||
|
||||
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):
|
||||
"""
|
||||
@ -158,8 +248,6 @@ class SalesOrderTest(OrderTest):
|
||||
|
||||
response = self.get(url)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
data = response.data
|
||||
|
||||
self.assertEqual(data['pk'], 1)
|
||||
@ -168,6 +256,87 @@ class SalesOrderTest(OrderTest):
|
||||
|
||||
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)
|
||||
|
@ -1010,6 +1010,15 @@ class OrderParts(AjaxView):
|
||||
|
||||
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):
|
||||
""" 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.
|
||||
@ -1244,9 +1253,10 @@ class OrderParts(AjaxView):
|
||||
valid = False
|
||||
|
||||
if form_step == 'select_parts':
|
||||
# No errors? Proceed to PO selection form
|
||||
if part_errors is False:
|
||||
# No errors? and the price-update button was not used to submit? Proceed to PO selection form
|
||||
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.form_step = 'select_purchase_orders' # set step (important for get_data)
|
||||
|
||||
else:
|
||||
self.ajax_template_name = 'order/order_wizard/select_parts.html'
|
||||
|
@ -111,6 +111,13 @@ class PartCategoryResource(ModelResource):
|
||||
PartCategory.objects.rebuild()
|
||||
|
||||
|
||||
class PartCategoryInline(admin.TabularInline):
|
||||
"""
|
||||
Inline for PartCategory model
|
||||
"""
|
||||
model = PartCategory
|
||||
|
||||
|
||||
class PartCategoryAdmin(ImportExportModelAdmin):
|
||||
|
||||
resource_class = PartCategoryResource
|
||||
@ -119,6 +126,10 @@ class PartCategoryAdmin(ImportExportModelAdmin):
|
||||
|
||||
search_fields = ('name', 'description')
|
||||
|
||||
inlines = [
|
||||
PartCategoryInline,
|
||||
]
|
||||
|
||||
|
||||
class PartRelatedAdmin(admin.ModelAdmin):
|
||||
''' Class to manage PartRelated objects '''
|
||||
|
@ -706,6 +706,7 @@ class PartList(generics.ListCreateAPIView):
|
||||
'creation_date',
|
||||
'IPN',
|
||||
'in_stock',
|
||||
'category',
|
||||
]
|
||||
|
||||
# Default ordering
|
||||
|
@ -39,7 +39,8 @@ class PartConfig(AppConfig):
|
||||
logger.debug("InvenTree: Checking Part image thumbnails")
|
||||
|
||||
try:
|
||||
for part in Part.objects.all():
|
||||
# Only check parts which have images
|
||||
for part in Part.objects.exclude(image=None):
|
||||
if part.image:
|
||||
url = part.image.thumbnail.name
|
||||
loc = os.path.join(settings.MEDIA_ROOT, url)
|
||||
@ -50,8 +51,7 @@ class PartConfig(AppConfig):
|
||||
part.image.render_variations(replace=False)
|
||||
except FileNotFoundError:
|
||||
logger.warning(f"Image file '{part.image}' missing")
|
||||
part.image = None
|
||||
part.save()
|
||||
pass
|
||||
except UnidentifiedImageError:
|
||||
logger.warning(f"Image file '{part.image}' is invalid")
|
||||
except (OperationalError, ProgrammingError):
|
||||
|
@ -6,7 +6,7 @@
|
||||
name: 'M2x4 LPHS'
|
||||
description: 'M2x4 low profile head screw'
|
||||
category: 8
|
||||
link: www.acme.com/parts/m2x4lphs
|
||||
link: http://www.acme.com/parts/m2x4lphs
|
||||
tree_id: 0
|
||||
purchaseable: True
|
||||
level: 0
|
||||
@ -56,6 +56,7 @@
|
||||
fields:
|
||||
name: 'C_22N_0805'
|
||||
description: '22nF capacitor in 0805 package'
|
||||
purchaseable: true
|
||||
category: 3
|
||||
tree_id: 0
|
||||
level: 0
|
||||
|
@ -71,6 +71,7 @@ def migrate_currencies(apps, schema_editor):
|
||||
|
||||
count += 1
|
||||
|
||||
if count > 0:
|
||||
print(f"Updated {count} SupplierPriceBreak rows")
|
||||
|
||||
def reverse_currencies(apps, schema_editor):
|
||||
|
17
InvenTree/part/migrations/0068_part_unique_part.py
Normal file
17
InvenTree/part/migrations/0068_part_unique_part.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -321,6 +321,9 @@ class Part(MPTTModel):
|
||||
verbose_name = _("Part")
|
||||
verbose_name_plural = _("Parts")
|
||||
ordering = ['name', ]
|
||||
constraints = [
|
||||
UniqueConstraint(fields=['name', 'IPN', 'revision'], name='unique_part')
|
||||
]
|
||||
|
||||
class MPTTMeta:
|
||||
# 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}'")
|
||||
previous.image.delete(save=False)
|
||||
|
||||
self.clean()
|
||||
self.validate_unique()
|
||||
self.full_clean()
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@ -643,23 +645,6 @@ class Part(MPTTModel):
|
||||
'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):
|
||||
"""
|
||||
Perform cleaning operations for the Part model
|
||||
@ -1494,16 +1479,17 @@ class Part(MPTTModel):
|
||||
|
||||
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
|
||||
|
||||
Args:
|
||||
quantity: Number of units to calculate price for
|
||||
buy: Include supplier 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:
|
||||
return None
|
||||
@ -1591,9 +1577,10 @@ class Part(MPTTModel):
|
||||
|
||||
- Supplier price (if purchased from suppliers)
|
||||
- BOM price (if built from other parts)
|
||||
- Internal price (if set for the part)
|
||||
|
||||
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
|
||||
@ -2514,7 +2501,9 @@ class BomItem(models.Model):
|
||||
def price_range(self):
|
||||
""" 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:
|
||||
return prange
|
||||
|
@ -7,12 +7,15 @@ from decimal import Decimal
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
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 sql_util.utils import SubqueryCount, SubquerySum
|
||||
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 .models import (BomItem, Part, PartAttachment, PartCategory,
|
||||
@ -300,7 +303,7 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
stock_item_count = 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)
|
||||
starred = serializers.SerializerMethodField()
|
||||
|
||||
|
@ -138,7 +138,7 @@
|
||||
<hr>
|
||||
<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>
|
||||
{% if price_history|length > 1 %}
|
||||
{% if price_history|length > 0 %}
|
||||
<div style="max-width: 99%; min-height: 300px">
|
||||
<canvas id="StockPriceChart"></canvas>
|
||||
</div>
|
||||
|
@ -239,13 +239,21 @@
|
||||
|
||||
enableDragAndDrop(
|
||||
'#part-thumb',
|
||||
"{% url 'part-image-upload' part.id %}",
|
||||
"{% url 'api-part-detail' part.id %}",
|
||||
{
|
||||
label: 'image',
|
||||
method: 'PATCH',
|
||||
success: function(data, status, xhr) {
|
||||
|
||||
// If image / thumbnail data present, live update
|
||||
if (data.image) {
|
||||
$('#part-image').attr('src', data.image);
|
||||
} else {
|
||||
// Otherwise, reload the page
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
$("#show-qr-code").click(function() {
|
||||
|
@ -5,6 +5,7 @@ over and above the built-in Django tags.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.conf import settings as djangosettings
|
||||
@ -114,6 +115,14 @@ def inventree_title(*args, **kwargs):
|
||||
return version.inventreeInstanceTitle()
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def python_version(*args, **kwargs):
|
||||
"""
|
||||
Return the current python version
|
||||
"""
|
||||
return sys.version.split(' ')[0]
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_version(*args, **kwargs):
|
||||
""" Return InvenTree version string """
|
||||
|
@ -1,14 +1,19 @@
|
||||
from rest_framework import status
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import PIL
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from part.models import Part
|
||||
from stock.models import StockItem
|
||||
from company.models import Company
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
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):
|
||||
"""
|
||||
@ -230,6 +235,18 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
response = self.client.get(url, data={'part': 10004})
|
||||
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)
|
||||
response = self.client.post(
|
||||
url,
|
||||
@ -237,6 +254,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
'part': 10000,
|
||||
'test_name': 'New Test',
|
||||
'required': True,
|
||||
'description': 'a test description'
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
@ -248,7 +266,8 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
url,
|
||||
data={
|
||||
'part': 10004,
|
||||
'test_name': " newtest"
|
||||
'test_name': " newtest",
|
||||
'description': 'dafsdf',
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
@ -293,6 +312,239 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
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):
|
||||
"""
|
||||
Tests to ensure that the various aggregation annotations are working correctly...
|
||||
@ -319,6 +571,8 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
|
||||
# Add a new part
|
||||
self.part = Part.objects.create(
|
||||
name='Banana',
|
||||
description='This is a banana',
|
||||
category=PartCategory.objects.get(pk=1),
|
||||
)
|
||||
|
||||
# Create some stock items associated with the part
|
||||
|
@ -23,7 +23,7 @@ class TestParams(TestCase):
|
||||
def test_str(self):
|
||||
|
||||
t1 = PartParameterTemplate.objects.get(pk=1)
|
||||
self.assertEquals(str(t1), 'Length (mm)')
|
||||
self.assertEqual(str(t1), 'Length (mm)')
|
||||
|
||||
p1 = PartParameter.objects.get(pk=1)
|
||||
self.assertEqual(str(p1), 'M2x4 LPHS : Length = 4mm')
|
||||
|
@ -11,7 +11,7 @@ from django.core.exceptions import ValidationError
|
||||
|
||||
import os
|
||||
|
||||
from .models import Part, PartTestTemplate
|
||||
from .models import Part, PartCategory, PartTestTemplate
|
||||
from .models import rename_part_image, match_part_names
|
||||
from .templatetags import inventree_extras
|
||||
|
||||
@ -78,6 +78,61 @@ class PartTest(TestCase):
|
||||
p = Part.objects.get(pk=100)
|
||||
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):
|
||||
self.assertEqual(self.r1.name, 'R_2K2_0805')
|
||||
self.assertEqual(self.r1.get_absolute_url(), '/part/3/')
|
||||
@ -277,21 +332,24 @@ class PartSettingsTest(TestCase):
|
||||
"""
|
||||
|
||||
# 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)
|
||||
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)
|
||||
Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='B')
|
||||
|
||||
# And attempt again with the same values (should fail)
|
||||
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
|
||||
InvenTreeSetting.set_setting('PART_ALLOW_DUPLICATE_IPN', False, self.user)
|
||||
|
||||
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()
|
||||
|
@ -981,7 +981,7 @@ class PartPricingView(PartDetail):
|
||||
part = self.get_part()
|
||||
# Stock history
|
||||
if part.total_stock > 1:
|
||||
ret = []
|
||||
price_history = []
|
||||
stock = part.stock_entries(include_variants=False, in_stock=True) # .order_by('purchase_order__date')
|
||||
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')
|
||||
else:
|
||||
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
|
||||
if part.has_bom:
|
||||
# get internal price setting
|
||||
use_internal = InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False)
|
||||
ctx_bom_parts = []
|
||||
# iterate over all bom-items
|
||||
for item in part.bom_items.all():
|
||||
ctx_item = {'name': str(item.sub_part)}
|
||||
price, qty = item.sub_part.get_price_range(quantity), item.quantity
|
||||
price, qty = item.sub_part.get_price_range(quantity, internal=use_internal), item.quantity
|
||||
|
||||
price_min, price_max = 0, 0
|
||||
if price: # check if price available
|
||||
|
@ -44,6 +44,13 @@ class LocationResource(ModelResource):
|
||||
StockLocation.objects.rebuild()
|
||||
|
||||
|
||||
class LocationInline(admin.TabularInline):
|
||||
"""
|
||||
Inline for sub-locations
|
||||
"""
|
||||
model = StockLocation
|
||||
|
||||
|
||||
class LocationAdmin(ImportExportModelAdmin):
|
||||
|
||||
resource_class = LocationResource
|
||||
@ -52,6 +59,10 @@ class LocationAdmin(ImportExportModelAdmin):
|
||||
|
||||
search_fields = ('name', 'description')
|
||||
|
||||
inlines = [
|
||||
LocationInline,
|
||||
]
|
||||
|
||||
|
||||
class StockItemResource(ModelResource):
|
||||
""" Class for managing StockItem data import/export """
|
||||
|
@ -199,6 +199,7 @@ def update_history(apps, schema_editor):
|
||||
update_count += 1
|
||||
|
||||
|
||||
if update_count > 0:
|
||||
print(f"\n==========================\nUpdated {update_count} StockItemHistory entries")
|
||||
|
||||
|
||||
|
78
InvenTree/stock/migrations/0064_auto_20210621_1724.py
Normal file
78
InvenTree/stock/migrations/0064_auto_20210621_1724.py
Normal 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)
|
||||
]
|
@ -81,6 +81,7 @@ class StockItemSerializer(InvenTreeModelSerializer):
|
||||
'belongs_to',
|
||||
'build',
|
||||
'customer',
|
||||
'purchase_order',
|
||||
'sales_order',
|
||||
'supplier_part',
|
||||
'supplier_part__supplier',
|
||||
@ -163,6 +164,10 @@ class StockItemSerializer(InvenTreeModelSerializer):
|
||||
|
||||
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):
|
||||
""" Return purchase_price (Money field) as string (includes currency) """
|
||||
|
||||
@ -208,10 +213,13 @@ class StockItemSerializer(InvenTreeModelSerializer):
|
||||
'packaging',
|
||||
'part',
|
||||
'part_detail',
|
||||
'purchase_order',
|
||||
'purchase_order_reference',
|
||||
'pk',
|
||||
'quantity',
|
||||
'required_tests',
|
||||
'sales_order',
|
||||
'sales_order_reference',
|
||||
'serial',
|
||||
'stale',
|
||||
'status',
|
||||
|
@ -325,7 +325,7 @@
|
||||
<td><a href="{% url 'po-detail' item.purchase_order.id %}">{{ item.purchase_order }}</a></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if item.purchase_price %}
|
||||
{% if item.purchase_price != None %}
|
||||
<tr>
|
||||
<td><span class='fas fa-dollar-sign'></span></td>
|
||||
<td>{% trans "Purchase Price" %}</td>
|
||||
@ -350,7 +350,12 @@
|
||||
<tr>
|
||||
<td><span class='fas fa-industry'></span></td>
|
||||
<td>{% trans "Manufacturer" %}</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>
|
||||
<td><span class='fas fa-hashtag'></span></td>
|
||||
|
@ -20,6 +20,7 @@
|
||||
{% 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_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" %}
|
||||
<tr><td colspan='5 '></td></tr>
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_TEMPLATE" icon="fa-clone" %}
|
||||
|
@ -34,6 +34,11 @@
|
||||
<td>{% trans "API Version" %}</td>
|
||||
<td>{% inventree_api_version %}{% include "clip.html" %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class='fas fa-hashtag'></span></td>
|
||||
<td>{% trans "Python Version" %}</td>
|
||||
<td>{% python_version %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class='fas fa-hashtag'></span></td>
|
||||
<td>{% trans "Django Version" %}</td>
|
||||
|
@ -418,6 +418,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
sub_part_detail: true,
|
||||
sub_part_trackable: trackable,
|
||||
},
|
||||
disablePagination: true,
|
||||
formatNoMatches: function() {
|
||||
return '{% trans "No BOM items found" %}';
|
||||
},
|
||||
@ -667,6 +668,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
{
|
||||
field: 'sub_part_detail.stock',
|
||||
title: '{% trans "Available" %}',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
field: 'allocated',
|
||||
@ -686,14 +688,13 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
return makeProgressBar(allocated, required);
|
||||
},
|
||||
sorter: function(valA, valB, rowA, rowB) {
|
||||
// Custom sorting function for progress bars
|
||||
|
||||
var aA = sumAllocations(rowA);
|
||||
var aB = sumAllocations(rowB);
|
||||
|
||||
var qA = rowA.quantity;
|
||||
var qB = rowB.quantity;
|
||||
|
||||
qA *= output.quantity;
|
||||
qB *= output.quantity;
|
||||
var qA = requiredQuantity(rowA);
|
||||
var qB = requiredQuantity(rowB);
|
||||
|
||||
// Handle the case where both numerators are zero
|
||||
if ((aA == 0) && (aB == 0)) {
|
||||
@ -713,6 +714,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
return (qA < qB) ? 1 : -1;
|
||||
}
|
||||
|
||||
if (progressA == progressB) return 0;
|
||||
|
||||
return (progressA < progressB) ? 1 : -1;
|
||||
}
|
||||
},
|
||||
|
@ -126,7 +126,7 @@ function loadManufacturerPartTable(table, url, options) {
|
||||
queryParams: filters,
|
||||
name: 'manufacturerparts',
|
||||
groupBy: false,
|
||||
formatNoMatches: function() { return "{% trans "No manufacturer parts found" %}"; },
|
||||
formatNoMatches: function() { return '{% trans "No manufacturer parts found" %}'; },
|
||||
columns: [
|
||||
{
|
||||
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) {
|
||||
/*
|
||||
* Load supplier part table
|
||||
@ -224,7 +325,7 @@ function loadSupplierPartTable(table, url, options) {
|
||||
queryParams: filters,
|
||||
name: 'supplierparts',
|
||||
groupBy: false,
|
||||
formatNoMatches: function() { return "{% trans "No supplier parts found" %}"; },
|
||||
formatNoMatches: function() { return '{% trans "No supplier parts found" %}'; },
|
||||
columns: [
|
||||
{
|
||||
checkbox: true,
|
||||
@ -260,7 +361,7 @@ function loadSupplierPartTable(table, url, options) {
|
||||
{
|
||||
sortable: true,
|
||||
field: 'supplier',
|
||||
title: "{% trans "Supplier" %}",
|
||||
title: '{% trans "Supplier" %}',
|
||||
formatter: function(value, row, index, field) {
|
||||
if (value) {
|
||||
var name = row.supplier_detail.name;
|
||||
@ -276,7 +377,7 @@ function loadSupplierPartTable(table, url, options) {
|
||||
{
|
||||
sortable: true,
|
||||
field: 'SKU',
|
||||
title: "{% trans "Supplier Part" %}",
|
||||
title: '{% trans "Supplier Part" %}',
|
||||
formatter: function(value, row, index, field) {
|
||||
return renderLink(value, `/supplier-part/${row.pk}/`);
|
||||
}
|
||||
|
@ -447,6 +447,7 @@ function loadPartTable(table, url, options={}) {
|
||||
|
||||
columns.push({
|
||||
sortable: true,
|
||||
sortName: 'category',
|
||||
field: 'category_detail',
|
||||
title: '{% trans "Category" %}',
|
||||
formatter: function(value, row, index, field) {
|
||||
|
@ -179,27 +179,32 @@ function loadStockTestResultsTable(table, options) {
|
||||
var match = false;
|
||||
var override = false;
|
||||
|
||||
// Extract the simplified test key
|
||||
var key = item.key;
|
||||
|
||||
// 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) {
|
||||
|
||||
item.test_name = row.test_name;
|
||||
item.required = row.required;
|
||||
|
||||
match = true;
|
||||
|
||||
if (row.result == null) {
|
||||
item.parent = parent_node;
|
||||
tableData[index] = item;
|
||||
tableData[idx] = item;
|
||||
override = true;
|
||||
} else {
|
||||
item.parent = row.pk;
|
||||
}
|
||||
|
||||
match = true;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// No match could be found
|
||||
if (!match) {
|
||||
@ -603,7 +608,6 @@ function loadStockTable(table, options) {
|
||||
|
||||
// REJECTED
|
||||
if (row.status == {{ StockStatus.REJECTED }}) {
|
||||
console.log("REJECTED - {{ StockStatus.REJECTED }}");
|
||||
html += makeIconBadge('fa-times-circle icon-red', '{% trans "Stock item has been rejected" %}');
|
||||
}
|
||||
// LOST
|
||||
@ -660,6 +664,27 @@ function loadStockTable(table, options) {
|
||||
title: '{% trans "Last Updated" %}',
|
||||
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',
|
||||
title: '{% trans "Purchase Price" %}',
|
||||
|
@ -134,12 +134,14 @@ $.fn.inventreeTable = function(options) {
|
||||
var varName = tableName + '-pagesize';
|
||||
|
||||
// Pagingation options (can be server-side or client-side as specified by the caller)
|
||||
if (!options.disablePagination) {
|
||||
options.pagination = true;
|
||||
options.paginationVAlign = options.paginationVAlign || 'both';
|
||||
options.pageSize = inventreeLoad(varName, 25);
|
||||
options.pageList = [25, 50, 100, 250, 'all'];
|
||||
options.totalField = 'count';
|
||||
options.dataField = 'results';
|
||||
}
|
||||
|
||||
// Extract query params
|
||||
var filters = options.queryParams || options.filters || {};
|
||||
|
@ -43,6 +43,9 @@
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block pre_content_panels %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content_panels %}
|
||||
<div class='panel panel-default panel-inventree'>
|
||||
<div class='panel-heading'>
|
||||
@ -63,6 +66,9 @@
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block post_content_panels %}
|
||||
{% endblock %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
|
@ -13,7 +13,7 @@ class UsersConfig(AppConfig):
|
||||
|
||||
def ready(self):
|
||||
|
||||
if canAppAccessDatabase():
|
||||
if canAppAccessDatabase(allow_test=True):
|
||||
|
||||
try:
|
||||
self.assign_permissions()
|
||||
|
@ -85,6 +85,7 @@ class RuleSet(models.Model):
|
||||
'part_partstar',
|
||||
'company_supplierpart',
|
||||
'company_manufacturerpart',
|
||||
'company_manufacturerpartparameter',
|
||||
],
|
||||
'stock_location': [
|
||||
'stock_stocklocation',
|
||||
@ -116,6 +117,8 @@ class RuleSet(models.Model):
|
||||
'order_purchaseorderattachment',
|
||||
'order_purchaseorderlineitem',
|
||||
'company_supplierpart',
|
||||
'company_manufacturerpart',
|
||||
'company_manufacturerpartparameter',
|
||||
],
|
||||
'sales_order': [
|
||||
'company_company',
|
||||
@ -273,7 +276,7 @@ def update_group_roles(group, debug=False):
|
||||
|
||||
"""
|
||||
|
||||
if not canAppAccessDatabase():
|
||||
if not canAppAccessDatabase(allow_test=True):
|
||||
return
|
||||
|
||||
# List of permissions already associated with this group
|
||||
|
23
RELEASE.md
Normal file
23
RELEASE.md
Normal 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)
|
18
tasks.py
18
tasks.py
@ -251,12 +251,15 @@ def content_excludes():
|
||||
"contenttypes",
|
||||
"sessions.session",
|
||||
"auth.permission",
|
||||
"authtoken.token",
|
||||
"error_report.error",
|
||||
"admin.logentry",
|
||||
"django_q.schedule",
|
||||
"django_q.task",
|
||||
"django_q.ormq",
|
||||
"users.owner",
|
||||
"exchange.rate",
|
||||
"exchange.exchangebackend",
|
||||
]
|
||||
|
||||
output = ""
|
||||
@ -362,6 +365,21 @@ def import_records(c, filename='data.json'):
|
||||
|
||||
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])
|
||||
def import_fixtures(c):
|
||||
"""
|
||||
|
Loading…
Reference in New Issue
Block a user