mirror of
https://github.com/inventree/InvenTree.git
synced 2025-05-03 13:58:47 +00:00
Merge branch 'master' of github.com:inventree/InvenTree into exchange_rate_task
This commit is contained in:
commit
372d252333
3
.github/workflows/docker_publish.yaml
vendored
3
.github/workflows/docker_publish.yaml
vendored
@ -13,6 +13,9 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
|
- name: Check Release tag
|
||||||
|
run: |
|
||||||
|
python3 ci/check_version_number.py ${{ github.event.release.tag_name }}
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v1
|
uses: docker/setup-qemu-action@v1
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
|
5
.gitignore
vendored
5
.gitignore
vendored
@ -67,4 +67,7 @@ secret_key.txt
|
|||||||
htmlcov/
|
htmlcov/
|
||||||
|
|
||||||
# Development files
|
# Development files
|
||||||
dev/
|
dev/
|
||||||
|
|
||||||
|
# Locale stats file
|
||||||
|
locale_stats.json
|
||||||
|
@ -344,13 +344,15 @@ def GetExportFormats():
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def DownloadFile(data, filename, content_type='application/text'):
|
def DownloadFile(data, filename, content_type='application/text', inline=False):
|
||||||
""" Create a dynamic file for the user to download.
|
"""
|
||||||
|
Create a dynamic file for the user to download.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data: Raw file data (string or bytes)
|
data: Raw file data (string or bytes)
|
||||||
filename: Filename for the file download
|
filename: Filename for the file download
|
||||||
content_type: Content type for the download
|
content_type: Content type for the download
|
||||||
|
inline: Download "inline" or as attachment? (Default = attachment)
|
||||||
|
|
||||||
Return:
|
Return:
|
||||||
A StreamingHttpResponse object wrapping the supplied data
|
A StreamingHttpResponse object wrapping the supplied data
|
||||||
@ -365,7 +367,10 @@ def DownloadFile(data, filename, content_type='application/text'):
|
|||||||
|
|
||||||
response = StreamingHttpResponse(wrapper, content_type=content_type)
|
response = StreamingHttpResponse(wrapper, content_type=content_type)
|
||||||
response['Content-Length'] = len(data)
|
response['Content-Length'] = len(data)
|
||||||
response['Content-Disposition'] = 'attachment; filename={f}'.format(f=filename)
|
|
||||||
|
disposition = "inline" if inline else "attachment"
|
||||||
|
|
||||||
|
response['Content-Disposition'] = f'{disposition}; filename={filename}'
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
@ -85,8 +85,10 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, instance=None, data=empty, **kwargs):
|
def __init__(self, instance=None, data=empty, **kwargs):
|
||||||
|
"""
|
||||||
# self.instance = instance
|
Custom __init__ routine to ensure that *default* values (as specified in the ORM)
|
||||||
|
are used by the DRF serializers, *if* the values are not provided by the user.
|
||||||
|
"""
|
||||||
|
|
||||||
# If instance is None, we are creating a new instance
|
# If instance is None, we are creating a new instance
|
||||||
if instance is None and data is not empty:
|
if instance is None and data is not empty:
|
||||||
@ -193,7 +195,15 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
|
|||||||
try:
|
try:
|
||||||
instance.full_clean()
|
instance.full_clean()
|
||||||
except (ValidationError, DjangoValidationError) as exc:
|
except (ValidationError, DjangoValidationError) as exc:
|
||||||
raise ValidationError(detail=serializers.as_serializer_error(exc))
|
|
||||||
|
data = exc.message_dict
|
||||||
|
|
||||||
|
# Change '__all__' key (django style) to 'non_field_errors' (DRF style)
|
||||||
|
if '__all__' in data:
|
||||||
|
data['non_field_errors'] = data['__all__']
|
||||||
|
del data['__all__']
|
||||||
|
|
||||||
|
raise ValidationError(data)
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
@ -10,11 +10,14 @@ import common.models
|
|||||||
|
|
||||||
INVENTREE_SW_VERSION = "0.5.0 pre"
|
INVENTREE_SW_VERSION = "0.5.0 pre"
|
||||||
|
|
||||||
INVENTREE_API_VERSION = 8
|
INVENTREE_API_VERSION = 9
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||||
|
|
||||||
|
v9 -> 2021-08-09
|
||||||
|
- Adds "price_string" to part pricing serializers
|
||||||
|
|
||||||
v8 -> 2021-07-19
|
v8 -> 2021-07-19
|
||||||
- Refactors the API interface for SupplierPart and ManufacturerPart models
|
- Refactors the API interface for SupplierPart and ManufacturerPart models
|
||||||
- ManufacturerPart objects can no longer be created via the SupplierPart API endpoint
|
- ManufacturerPart objects can no longer be created via the SupplierPart API endpoint
|
||||||
|
@ -7,12 +7,15 @@ as JSON objects and passing them to modal forms (using jQuery / bootstrap).
|
|||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from django.http import HttpResponse, JsonResponse, HttpResponseRedirect
|
from django.http import HttpResponse, JsonResponse, HttpResponseRedirect
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||||
|
|
||||||
@ -800,6 +803,13 @@ class SettingsView(TemplateView):
|
|||||||
except:
|
except:
|
||||||
ctx["rates_updated"] = None
|
ctx["rates_updated"] = None
|
||||||
|
|
||||||
|
# load locale stats
|
||||||
|
STAT_FILE = os.path.abspath(os.path.join(settings.BASE_DIR, 'InvenTree/locale_stats.json'))
|
||||||
|
try:
|
||||||
|
ctx["locale_stats"] = json.load(open(STAT_FILE, 'r'))
|
||||||
|
except:
|
||||||
|
ctx["locale_stats"] = {}
|
||||||
|
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
@ -637,7 +637,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||||||
'PART_PURCHASEABLE': {
|
'PART_PURCHASEABLE': {
|
||||||
'name': _('Purchaseable'),
|
'name': _('Purchaseable'),
|
||||||
'description': _('Parts are purchaseable by default'),
|
'description': _('Parts are purchaseable by default'),
|
||||||
'default': False,
|
'default': True,
|
||||||
'validator': bool,
|
'validator': bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -662,6 +662,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||||||
'validator': bool,
|
'validator': bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
# TODO: Remove this setting in future, new API forms make this not useful
|
||||||
'PART_SHOW_QUANTITY_IN_FORMS': {
|
'PART_SHOW_QUANTITY_IN_FORMS': {
|
||||||
'name': _('Show Quantity in Forms'),
|
'name': _('Show Quantity in Forms'),
|
||||||
'description': _('Display available part quantity in some forms'),
|
'description': _('Display available part quantity in some forms'),
|
||||||
@ -925,6 +926,20 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
|
|||||||
'validator': bool,
|
'validator': bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"LABEL_INLINE": {
|
||||||
|
'name': _('Inline label display'),
|
||||||
|
'description': _('Display PDF labels in the browser, instead of downloading as a file'),
|
||||||
|
'default': True,
|
||||||
|
'validator': bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
"REPORT_INLINE": {
|
||||||
|
'name': _('Inline report display'),
|
||||||
|
'description': _('Display PDF reports in the browser, instead of downloading as a file'),
|
||||||
|
'default': False,
|
||||||
|
'validator': bool,
|
||||||
|
},
|
||||||
|
|
||||||
'SEARCH_PREVIEW_RESULTS': {
|
'SEARCH_PREVIEW_RESULTS': {
|
||||||
'name': _('Search Preview Results'),
|
'name': _('Search Preview Results'),
|
||||||
'description': _('Number of results to show in search preview window'),
|
'description': _('Number of results to show in search preview window'),
|
||||||
@ -964,7 +979,10 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_filters(cls, key, **kwargs):
|
def get_filters(cls, key, **kwargs):
|
||||||
return {'key__iexact': key, 'user__id': kwargs['user'].id}
|
return {
|
||||||
|
'key__iexact': key,
|
||||||
|
'user__id': kwargs['user'].id
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class PriceBreak(models.Model):
|
class PriceBreak(models.Model):
|
||||||
|
@ -109,10 +109,13 @@ class LabelPrintMixin:
|
|||||||
else:
|
else:
|
||||||
pdf = outputs[0].get_document().write_pdf()
|
pdf = outputs[0].get_document().write_pdf()
|
||||||
|
|
||||||
|
inline = common.models.InvenTreeUserSetting.get_setting('LABEL_INLINE', user=request.user)
|
||||||
|
|
||||||
return InvenTree.helpers.DownloadFile(
|
return InvenTree.helpers.DownloadFile(
|
||||||
pdf,
|
pdf,
|
||||||
label_name,
|
label_name,
|
||||||
content_type='application/pdf'
|
content_type='application/pdf',
|
||||||
|
inline=inline
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -23,6 +23,7 @@ from djmoney.money import Money
|
|||||||
from djmoney.contrib.exchange.models import convert_money
|
from djmoney.contrib.exchange.models import convert_money
|
||||||
from djmoney.contrib.exchange.exceptions import MissingRate
|
from djmoney.contrib.exchange.exceptions import MissingRate
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
from .models import Part, PartCategory, BomItem
|
from .models import Part, PartCategory, BomItem
|
||||||
from .models import PartParameter, PartParameterTemplate
|
from .models import PartParameter, PartParameterTemplate
|
||||||
@ -30,6 +31,7 @@ from .models import PartAttachment, PartTestTemplate
|
|||||||
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||||
from .models import PartCategoryParameterTemplate
|
from .models import PartCategoryParameterTemplate
|
||||||
|
|
||||||
|
from stock.models import StockItem
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
from build.models import Build
|
from build.models import Build
|
||||||
|
|
||||||
@ -628,16 +630,75 @@ class PartList(generics.ListCreateAPIView):
|
|||||||
else:
|
else:
|
||||||
return Response(data)
|
return Response(data)
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def create(self, request, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
We wish to save the user who created this part!
|
We wish to save the user who created this part!
|
||||||
|
|
||||||
Note: Implementation copied from DRF class CreateModelMixin
|
Note: Implementation copied from DRF class CreateModelMixin
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
serializer = self.get_serializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
part = serializer.save()
|
part = serializer.save()
|
||||||
part.creation_user = self.request.user
|
part.creation_user = self.request.user
|
||||||
part.save()
|
|
||||||
|
# Optionally copy templates from category or parent category
|
||||||
|
copy_templates = {
|
||||||
|
'main': str2bool(request.data.get('copy_category_templates', False)),
|
||||||
|
'parent': str2bool(request.data.get('copy_parent_templates', False))
|
||||||
|
}
|
||||||
|
|
||||||
|
part.save(**{'add_category_templates': copy_templates})
|
||||||
|
|
||||||
|
# Optionally copy data from another part (e.g. when duplicating)
|
||||||
|
copy_from = request.data.get('copy_from', None)
|
||||||
|
|
||||||
|
if copy_from is not None:
|
||||||
|
|
||||||
|
try:
|
||||||
|
original = Part.objects.get(pk=copy_from)
|
||||||
|
|
||||||
|
copy_bom = str2bool(request.data.get('copy_bom', False))
|
||||||
|
copy_parameters = str2bool(request.data.get('copy_parameters', False))
|
||||||
|
copy_image = str2bool(request.data.get('copy_image', True))
|
||||||
|
|
||||||
|
# Copy image?
|
||||||
|
if copy_image:
|
||||||
|
part.image = original.image
|
||||||
|
part.save()
|
||||||
|
|
||||||
|
# Copy BOM?
|
||||||
|
if copy_bom:
|
||||||
|
part.copy_bom_from(original)
|
||||||
|
|
||||||
|
# Copy parameter data?
|
||||||
|
if copy_parameters:
|
||||||
|
part.copy_parameters_from(original)
|
||||||
|
|
||||||
|
except (ValueError, Part.DoesNotExist):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Optionally create initial stock item
|
||||||
|
try:
|
||||||
|
initial_stock = Decimal(request.data.get('initial_stock', 0))
|
||||||
|
|
||||||
|
if initial_stock > 0 and part.default_location is not None:
|
||||||
|
|
||||||
|
stock_item = StockItem(
|
||||||
|
part=part,
|
||||||
|
quantity=initial_stock,
|
||||||
|
location=part.default_location,
|
||||||
|
)
|
||||||
|
|
||||||
|
stock_item.save(user=request.user)
|
||||||
|
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
headers = self.get_success_headers(serializer.data)
|
||||||
|
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||||
|
|
||||||
def get_queryset(self, *args, **kwargs):
|
def get_queryset(self, *args, **kwargs):
|
||||||
|
|
||||||
|
@ -177,82 +177,6 @@ class SetPartCategoryForm(forms.Form):
|
|||||||
part_category = TreeNodeChoiceField(queryset=PartCategory.objects.all(), required=True, help_text=_('Select part category'))
|
part_category = TreeNodeChoiceField(queryset=PartCategory.objects.all(), required=True, help_text=_('Select part category'))
|
||||||
|
|
||||||
|
|
||||||
class EditPartForm(HelperForm):
|
|
||||||
"""
|
|
||||||
Form for editing a Part object.
|
|
||||||
"""
|
|
||||||
|
|
||||||
field_prefix = {
|
|
||||||
'keywords': 'fa-key',
|
|
||||||
'link': 'fa-link',
|
|
||||||
'IPN': 'fa-hashtag',
|
|
||||||
'default_expiry': 'fa-stopwatch',
|
|
||||||
}
|
|
||||||
|
|
||||||
bom_copy = forms.BooleanField(required=False,
|
|
||||||
initial=True,
|
|
||||||
help_text=_("Duplicate all BOM data for this part"),
|
|
||||||
label=_('Copy BOM'),
|
|
||||||
widget=forms.HiddenInput())
|
|
||||||
|
|
||||||
parameters_copy = forms.BooleanField(required=False,
|
|
||||||
initial=True,
|
|
||||||
help_text=_("Duplicate all parameter data for this part"),
|
|
||||||
label=_('Copy Parameters'),
|
|
||||||
widget=forms.HiddenInput())
|
|
||||||
|
|
||||||
confirm_creation = forms.BooleanField(required=False,
|
|
||||||
initial=False,
|
|
||||||
help_text=_('Confirm part creation'),
|
|
||||||
widget=forms.HiddenInput())
|
|
||||||
|
|
||||||
selected_category_templates = forms.BooleanField(required=False,
|
|
||||||
initial=False,
|
|
||||||
label=_('Include category parameter templates'),
|
|
||||||
widget=forms.HiddenInput())
|
|
||||||
|
|
||||||
parent_category_templates = forms.BooleanField(required=False,
|
|
||||||
initial=False,
|
|
||||||
label=_('Include parent categories parameter templates'),
|
|
||||||
widget=forms.HiddenInput())
|
|
||||||
|
|
||||||
initial_stock = forms.IntegerField(required=False,
|
|
||||||
initial=0,
|
|
||||||
label=_('Initial stock amount'),
|
|
||||||
help_text=_('Create stock for this part'))
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Part
|
|
||||||
fields = [
|
|
||||||
'confirm_creation',
|
|
||||||
'category',
|
|
||||||
'selected_category_templates',
|
|
||||||
'parent_category_templates',
|
|
||||||
'name',
|
|
||||||
'IPN',
|
|
||||||
'description',
|
|
||||||
'revision',
|
|
||||||
'bom_copy',
|
|
||||||
'parameters_copy',
|
|
||||||
'keywords',
|
|
||||||
'variant_of',
|
|
||||||
'link',
|
|
||||||
'default_location',
|
|
||||||
'default_supplier',
|
|
||||||
'default_expiry',
|
|
||||||
'units',
|
|
||||||
'minimum_stock',
|
|
||||||
'initial_stock',
|
|
||||||
'component',
|
|
||||||
'assembly',
|
|
||||||
'is_template',
|
|
||||||
'trackable',
|
|
||||||
'purchaseable',
|
|
||||||
'salable',
|
|
||||||
'virtual',
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class EditPartParameterTemplateForm(HelperForm):
|
class EditPartParameterTemplateForm(HelperForm):
|
||||||
""" Form for editing a PartParameterTemplate object """
|
""" Form for editing a PartParameterTemplate object """
|
||||||
|
|
||||||
|
@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 3.2.4 on 2021-08-07 11:40
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import part.models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('part', '0070_alter_part_variant_of'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='partparametertemplate',
|
||||||
|
name='name',
|
||||||
|
field=models.CharField(help_text='Parameter Name', max_length=100, unique=True, validators=[part.models.validate_template_name], verbose_name='Name'),
|
||||||
|
),
|
||||||
|
]
|
@ -34,7 +34,6 @@ from stdimage.models import StdImageField
|
|||||||
|
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from rapidfuzz import fuzz
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
from InvenTree import helpers
|
from InvenTree import helpers
|
||||||
@ -235,57 +234,6 @@ def rename_part_image(instance, filename):
|
|||||||
return os.path.join(base, fname)
|
return os.path.join(base, fname)
|
||||||
|
|
||||||
|
|
||||||
def match_part_names(match, threshold=80, reverse=True, compare_length=False):
|
|
||||||
""" Return a list of parts whose name matches the search term using fuzzy search.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
match: Term to match against
|
|
||||||
threshold: Match percentage that must be exceeded (default = 65)
|
|
||||||
reverse: Ordering for search results (default = True - highest match is first)
|
|
||||||
compare_length: Include string length checks
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A sorted dict where each element contains the following key:value pairs:
|
|
||||||
- 'part' : The matched part
|
|
||||||
- 'ratio' : The matched ratio
|
|
||||||
"""
|
|
||||||
|
|
||||||
match = str(match).strip().lower()
|
|
||||||
|
|
||||||
if len(match) == 0:
|
|
||||||
return []
|
|
||||||
|
|
||||||
parts = Part.objects.all()
|
|
||||||
|
|
||||||
matches = []
|
|
||||||
|
|
||||||
for part in parts:
|
|
||||||
compare = str(part.name).strip().lower()
|
|
||||||
|
|
||||||
if len(compare) == 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
ratio = fuzz.partial_token_sort_ratio(compare, match)
|
|
||||||
|
|
||||||
if compare_length:
|
|
||||||
# Also employ primitive length comparison
|
|
||||||
# TODO - Improve this somewhat...
|
|
||||||
l_min = min(len(match), len(compare))
|
|
||||||
l_max = max(len(match), len(compare))
|
|
||||||
|
|
||||||
ratio *= (l_min / l_max)
|
|
||||||
|
|
||||||
if ratio >= threshold:
|
|
||||||
matches.append({
|
|
||||||
'part': part,
|
|
||||||
'ratio': round(ratio, 1)
|
|
||||||
})
|
|
||||||
|
|
||||||
matches = sorted(matches, key=lambda item: item['ratio'], reverse=reverse)
|
|
||||||
|
|
||||||
return matches
|
|
||||||
|
|
||||||
|
|
||||||
class PartManager(TreeManager):
|
class PartManager(TreeManager):
|
||||||
"""
|
"""
|
||||||
Defines a custom object manager for the Part model.
|
Defines a custom object manager for the Part model.
|
||||||
@ -409,7 +357,7 @@ class Part(MPTTModel):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Get category templates settings
|
# Get category templates settings
|
||||||
add_category_templates = kwargs.pop('add_category_templates', None)
|
add_category_templates = kwargs.pop('add_category_templates', False)
|
||||||
|
|
||||||
if self.pk:
|
if self.pk:
|
||||||
previous = Part.objects.get(pk=self.pk)
|
previous = Part.objects.get(pk=self.pk)
|
||||||
@ -437,39 +385,29 @@ class Part(MPTTModel):
|
|||||||
# Get part category
|
# Get part category
|
||||||
category = self.category
|
category = self.category
|
||||||
|
|
||||||
if category and add_category_templates:
|
if category is not None:
|
||||||
# Store templates added to part
|
|
||||||
template_list = []
|
template_list = []
|
||||||
|
|
||||||
# Create part parameters for selected category
|
parent_categories = category.get_ancestors(include_self=True)
|
||||||
category_templates = add_category_templates['main']
|
|
||||||
if category_templates:
|
for category in parent_categories:
|
||||||
for template in category.get_parameter_templates():
|
for template in category.get_parameter_templates():
|
||||||
parameter = PartParameter.create(part=self,
|
# Check that template wasn't already added
|
||||||
template=template.parameter_template,
|
if template.parameter_template not in template_list:
|
||||||
data=template.default_value,
|
|
||||||
save=True)
|
|
||||||
if parameter:
|
|
||||||
template_list.append(template.parameter_template)
|
template_list.append(template.parameter_template)
|
||||||
|
|
||||||
# Create part parameters for parent category
|
try:
|
||||||
category_templates = add_category_templates['parent']
|
PartParameter.create(
|
||||||
if category_templates:
|
part=self,
|
||||||
# Get parent categories
|
template=template.parameter_template,
|
||||||
parent_categories = category.get_ancestors()
|
data=template.default_value,
|
||||||
|
save=True
|
||||||
for category in parent_categories:
|
)
|
||||||
for template in category.get_parameter_templates():
|
except IntegrityError:
|
||||||
# Check that template wasn't already added
|
# PartParameter already exists
|
||||||
if template.parameter_template not in template_list:
|
pass
|
||||||
try:
|
|
||||||
PartParameter.create(part=self,
|
|
||||||
template=template.parameter_template,
|
|
||||||
data=template.default_value,
|
|
||||||
save=True)
|
|
||||||
except IntegrityError:
|
|
||||||
# PartParameter already exists
|
|
||||||
pass
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.full_name} - {self.description}"
|
return f"{self.full_name} - {self.description}"
|
||||||
@ -2205,6 +2143,16 @@ class PartTestTemplate(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_template_name(name):
|
||||||
|
"""
|
||||||
|
Prevent illegal characters in "name" field for PartParameterTemplate
|
||||||
|
"""
|
||||||
|
|
||||||
|
for c in "!@#$%^&*()<>{}[].,?/\|~`_+-=\'\"":
|
||||||
|
if c in str(name):
|
||||||
|
raise ValidationError(_(f"Illegal character in template name ({c})"))
|
||||||
|
|
||||||
|
|
||||||
class PartParameterTemplate(models.Model):
|
class PartParameterTemplate(models.Model):
|
||||||
"""
|
"""
|
||||||
A PartParameterTemplate provides a template for key:value pairs for extra
|
A PartParameterTemplate provides a template for key:value pairs for extra
|
||||||
@ -2243,7 +2191,15 @@ class PartParameterTemplate(models.Model):
|
|||||||
except PartParameterTemplate.DoesNotExist:
|
except PartParameterTemplate.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
name = models.CharField(max_length=100, verbose_name=_('Name'), help_text=_('Parameter Name'), unique=True)
|
name = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
verbose_name=_('Name'),
|
||||||
|
help_text=_('Parameter Name'),
|
||||||
|
unique=True,
|
||||||
|
validators=[
|
||||||
|
validate_template_name,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
units = models.CharField(max_length=25, verbose_name=_('Units'), help_text=_('Parameter Units'), blank=True)
|
units = models.CharField(max_length=25, verbose_name=_('Units'), help_text=_('Parameter Units'), blank=True)
|
||||||
|
|
||||||
|
@ -15,7 +15,8 @@ from djmoney.contrib.django_rest_framework import MoneyField
|
|||||||
|
|
||||||
from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
|
from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
|
||||||
InvenTreeImageSerializerField,
|
InvenTreeImageSerializerField,
|
||||||
InvenTreeModelSerializer)
|
InvenTreeModelSerializer,
|
||||||
|
InvenTreeMoneySerializer)
|
||||||
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
|
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
|
||||||
from stock.models import StockItem
|
from stock.models import StockItem
|
||||||
|
|
||||||
@ -102,7 +103,12 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
quantity = serializers.FloatField()
|
quantity = serializers.FloatField()
|
||||||
|
|
||||||
price = serializers.CharField()
|
price = InvenTreeMoneySerializer(
|
||||||
|
max_digits=19, decimal_places=4,
|
||||||
|
allow_null=True
|
||||||
|
)
|
||||||
|
|
||||||
|
price_string = serializers.CharField(source='price', read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = PartSellPriceBreak
|
model = PartSellPriceBreak
|
||||||
@ -111,6 +117,7 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
|
|||||||
'part',
|
'part',
|
||||||
'quantity',
|
'quantity',
|
||||||
'price',
|
'price',
|
||||||
|
'price_string',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -121,7 +128,12 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
quantity = serializers.FloatField()
|
quantity = serializers.FloatField()
|
||||||
|
|
||||||
price = serializers.CharField()
|
price = InvenTreeMoneySerializer(
|
||||||
|
max_digits=19, decimal_places=4,
|
||||||
|
allow_null=True
|
||||||
|
)
|
||||||
|
|
||||||
|
price_string = serializers.CharField(source='price', read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = PartInternalPriceBreak
|
model = PartInternalPriceBreak
|
||||||
@ -130,6 +142,7 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
|
|||||||
'part',
|
'part',
|
||||||
'quantity',
|
'quantity',
|
||||||
'price',
|
'price',
|
||||||
|
'price_string',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -264,25 +264,25 @@
|
|||||||
|
|
||||||
{% if roles.part.add %}
|
{% if roles.part.add %}
|
||||||
$("#part-create").click(function() {
|
$("#part-create").click(function() {
|
||||||
launchModalForm(
|
|
||||||
"{% url 'part-create' %}",
|
var fields = partFields({
|
||||||
{
|
create: true,
|
||||||
follow: true,
|
});
|
||||||
data: {
|
|
||||||
{% if category %}
|
{% if category %}
|
||||||
category: {{ category.id }}
|
fields.category.value = {{ category.pk }};
|
||||||
{% endif %}
|
{% endif %}
|
||||||
},
|
|
||||||
secondary: [
|
constructForm('{% url "api-part-list" %}', {
|
||||||
{
|
method: 'POST',
|
||||||
field: 'default_location',
|
fields: fields,
|
||||||
label: '{% trans "New Location" %}',
|
title: '{% trans "Create Part" %}',
|
||||||
title: '{% trans "Create new Stock Location" %}',
|
onSuccess: function(data) {
|
||||||
url: "{% url 'stock-location-create' %}",
|
// Follow the new part
|
||||||
}
|
location.href = `/part/${data.pk}/`;
|
||||||
]
|
},
|
||||||
}
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
@ -525,10 +525,11 @@
|
|||||||
loadPartVariantTable($('#variants-table'), {{ part.pk }});
|
loadPartVariantTable($('#variants-table'), {{ part.pk }});
|
||||||
|
|
||||||
$('#new-variant').click(function() {
|
$('#new-variant').click(function() {
|
||||||
launchModalForm(
|
|
||||||
"{% url 'make-part-variant' part.id %}",
|
duplicatePart(
|
||||||
|
{{ part.pk}},
|
||||||
{
|
{
|
||||||
follow: true,
|
variant: true,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -486,12 +486,7 @@
|
|||||||
|
|
||||||
{% if roles.part.add %}
|
{% if roles.part.add %}
|
||||||
$("#part-duplicate").click(function() {
|
$("#part-duplicate").click(function() {
|
||||||
launchModalForm(
|
duplicatePart({{ part.pk }});
|
||||||
"{% url 'part-duplicate' part.id %}",
|
|
||||||
{
|
|
||||||
follow: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
@ -204,6 +204,7 @@ def settings_value(key, *args, **kwargs):
|
|||||||
|
|
||||||
if 'user' in kwargs:
|
if 'user' in kwargs:
|
||||||
return InvenTreeUserSetting.get_setting(key, user=kwargs['user'])
|
return InvenTreeUserSetting.get_setting(key, user=kwargs['user'])
|
||||||
|
|
||||||
return InvenTreeSetting.get_setting(key)
|
return InvenTreeSetting.get_setting(key)
|
||||||
|
|
||||||
|
|
||||||
@ -269,7 +270,7 @@ def keyvalue(dict, key):
|
|||||||
usage:
|
usage:
|
||||||
{% mydict|keyvalue:mykey %}
|
{% mydict|keyvalue:mykey %}
|
||||||
"""
|
"""
|
||||||
return dict[key]
|
return dict.get(key)
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag()
|
@register.simple_tag()
|
||||||
|
@ -434,8 +434,8 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
self.assertTrue(data['active'])
|
self.assertTrue(data['active'])
|
||||||
self.assertFalse(data['virtual'])
|
self.assertFalse(data['virtual'])
|
||||||
|
|
||||||
# By default, parts are not purchaseable
|
# By default, parts are purchaseable
|
||||||
self.assertFalse(data['purchaseable'])
|
self.assertTrue(data['purchaseable'])
|
||||||
|
|
||||||
# Set the default 'purchaseable' status to True
|
# Set the default 'purchaseable' status to True
|
||||||
InvenTreeSetting.set_setting(
|
InvenTreeSetting.set_setting(
|
||||||
|
@ -12,7 +12,7 @@ from django.core.exceptions import ValidationError
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from .models import Part, PartCategory, PartTestTemplate
|
from .models import Part, PartCategory, PartTestTemplate
|
||||||
from .models import rename_part_image, match_part_names
|
from .models import rename_part_image
|
||||||
from .templatetags import inventree_extras
|
from .templatetags import inventree_extras
|
||||||
|
|
||||||
import part.settings
|
import part.settings
|
||||||
@ -163,12 +163,6 @@ class PartTest(TestCase):
|
|||||||
def test_copy(self):
|
def test_copy(self):
|
||||||
self.r2.deep_copy(self.r1, image=True, bom=True)
|
self.r2.deep_copy(self.r1, image=True, bom=True)
|
||||||
|
|
||||||
def test_match_names(self):
|
|
||||||
|
|
||||||
matches = match_part_names('M2x5 LPHS')
|
|
||||||
|
|
||||||
self.assertTrue(len(matches) > 0)
|
|
||||||
|
|
||||||
def test_sell_pricing(self):
|
def test_sell_pricing(self):
|
||||||
# check that the sell pricebreaks were loaded
|
# check that the sell pricebreaks were loaded
|
||||||
self.assertTrue(self.r1.has_price_breaks)
|
self.assertTrue(self.r1.has_price_breaks)
|
||||||
@ -281,7 +275,7 @@ class PartSettingsTest(TestCase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
self.assertTrue(part.settings.part_component_default())
|
self.assertTrue(part.settings.part_component_default())
|
||||||
self.assertFalse(part.settings.part_purchaseable_default())
|
self.assertTrue(part.settings.part_purchaseable_default())
|
||||||
self.assertFalse(part.settings.part_salable_default())
|
self.assertFalse(part.settings.part_salable_default())
|
||||||
self.assertFalse(part.settings.part_trackable_default())
|
self.assertFalse(part.settings.part_trackable_default())
|
||||||
|
|
||||||
@ -293,7 +287,7 @@ class PartSettingsTest(TestCase):
|
|||||||
part = self.make_part()
|
part = self.make_part()
|
||||||
|
|
||||||
self.assertTrue(part.component)
|
self.assertTrue(part.component)
|
||||||
self.assertFalse(part.purchaseable)
|
self.assertTrue(part.purchaseable)
|
||||||
self.assertFalse(part.salable)
|
self.assertFalse(part.salable)
|
||||||
self.assertFalse(part.trackable)
|
self.assertFalse(part.trackable)
|
||||||
|
|
||||||
|
@ -155,38 +155,6 @@ class PartDetailTest(PartViewTestCase):
|
|||||||
self.assertIn('streaming_content', dir(response))
|
self.assertIn('streaming_content', dir(response))
|
||||||
|
|
||||||
|
|
||||||
class PartTests(PartViewTestCase):
|
|
||||||
""" Tests for Part forms """
|
|
||||||
|
|
||||||
def test_part_create(self):
|
|
||||||
""" Launch form to create a new part """
|
|
||||||
response = self.client.get(reverse('part-create'), {'category': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
# And again, with an invalid category
|
|
||||||
response = self.client.get(reverse('part-create'), {'category': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
# And again, with no category
|
|
||||||
response = self.client.get(reverse('part-create'), {'name': 'Test part'}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
def test_part_duplicate(self):
|
|
||||||
""" Launch form to duplicate part """
|
|
||||||
|
|
||||||
# First try with an invalid part
|
|
||||||
response = self.client.get(reverse('part-duplicate', args=(9999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
response = self.client.get(reverse('part-duplicate', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
def test_make_variant(self):
|
|
||||||
|
|
||||||
response = self.client.get(reverse('make-part-variant', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
|
|
||||||
class PartRelatedTests(PartViewTestCase):
|
class PartRelatedTests(PartViewTestCase):
|
||||||
|
|
||||||
def test_valid_create(self):
|
def test_valid_create(self):
|
||||||
|
@ -40,8 +40,7 @@ part_detail_urls = [
|
|||||||
url(r'^bom-export/?', views.BomExport.as_view(), name='bom-export'),
|
url(r'^bom-export/?', views.BomExport.as_view(), name='bom-export'),
|
||||||
url(r'^bom-download/?', views.BomDownload.as_view(), name='bom-download'),
|
url(r'^bom-download/?', views.BomDownload.as_view(), name='bom-download'),
|
||||||
url(r'^validate-bom/', views.BomValidate.as_view(), name='bom-validate'),
|
url(r'^validate-bom/', views.BomValidate.as_view(), name='bom-validate'),
|
||||||
url(r'^duplicate/', views.PartDuplicate.as_view(), name='part-duplicate'),
|
|
||||||
url(r'^make-variant/', views.MakePartVariant.as_view(), name='make-part-variant'),
|
|
||||||
url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'),
|
url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'),
|
||||||
|
|
||||||
url(r'^bom-upload/?', views.BomUpload.as_view(), name='upload-bom'),
|
url(r'^bom-upload/?', views.BomUpload.as_view(), name='upload-bom'),
|
||||||
@ -81,9 +80,6 @@ category_urls = [
|
|||||||
# URL list for part web interface
|
# URL list for part web interface
|
||||||
part_urls = [
|
part_urls = [
|
||||||
|
|
||||||
# Create a new part
|
|
||||||
url(r'^new/?', views.PartCreate.as_view(), name='part-create'),
|
|
||||||
|
|
||||||
# Upload a part
|
# Upload a part
|
||||||
url(r'^import/', views.PartImport.as_view(), name='part-import'),
|
url(r'^import/', views.PartImport.as_view(), name='part-import'),
|
||||||
url(r'^import-api/', views.PartImportAjax.as_view(), name='api-part-import'),
|
url(r'^import-api/', views.PartImportAjax.as_view(), name='api-part-import'),
|
||||||
|
@ -14,8 +14,7 @@ from django.shortcuts import HttpResponseRedirect
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.views.generic import DetailView, ListView
|
from django.views.generic import DetailView, ListView
|
||||||
from django.forms.models import model_to_dict
|
from django.forms import HiddenInput
|
||||||
from django.forms import HiddenInput, CheckboxInput
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
|
|
||||||
@ -35,7 +34,6 @@ from .models import PartCategory, Part, PartRelated
|
|||||||
from .models import PartParameterTemplate
|
from .models import PartParameterTemplate
|
||||||
from .models import PartCategoryParameterTemplate
|
from .models import PartCategoryParameterTemplate
|
||||||
from .models import BomItem
|
from .models import BomItem
|
||||||
from .models import match_part_names
|
|
||||||
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||||
|
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
@ -44,7 +42,7 @@ from common.files import FileManager
|
|||||||
from common.views import FileManagementFormView, FileManagementAjaxView
|
from common.views import FileManagementFormView, FileManagementAjaxView
|
||||||
from common.forms import UploadFileForm, MatchFieldForm
|
from common.forms import UploadFileForm, MatchFieldForm
|
||||||
|
|
||||||
from stock.models import StockItem, StockLocation
|
from stock.models import StockLocation
|
||||||
|
|
||||||
import common.settings as inventree_settings
|
import common.settings as inventree_settings
|
||||||
|
|
||||||
@ -233,370 +231,6 @@ class PartSetCategory(AjaxUpdateView):
|
|||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
class MakePartVariant(AjaxCreateView):
|
|
||||||
""" View for creating a new variant based on an existing template Part
|
|
||||||
|
|
||||||
- Part <pk> is provided in the URL '/part/<pk>/make_variant/'
|
|
||||||
- Automatically copy relevent data (BOM, etc, etc)
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
model = Part
|
|
||||||
form_class = part_forms.EditPartForm
|
|
||||||
|
|
||||||
ajax_form_title = _('Create Variant')
|
|
||||||
ajax_template_name = 'part/variant_part.html'
|
|
||||||
|
|
||||||
def get_part_template(self):
|
|
||||||
return get_object_or_404(Part, id=self.kwargs['pk'])
|
|
||||||
|
|
||||||
def get_context_data(self):
|
|
||||||
return {
|
|
||||||
'part': self.get_part_template(),
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_form(self):
|
|
||||||
form = super(AjaxCreateView, self).get_form()
|
|
||||||
|
|
||||||
# Hide some variant-related fields
|
|
||||||
# form.fields['variant_of'].widget = HiddenInput()
|
|
||||||
|
|
||||||
# Force display of the 'bom_copy' widget
|
|
||||||
form.fields['bom_copy'].widget = CheckboxInput()
|
|
||||||
|
|
||||||
# Force display of the 'parameters_copy' widget
|
|
||||||
form.fields['parameters_copy'].widget = CheckboxInput()
|
|
||||||
|
|
||||||
return form
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
|
||||||
|
|
||||||
form = self.get_form()
|
|
||||||
context = self.get_context_data()
|
|
||||||
part_template = self.get_part_template()
|
|
||||||
|
|
||||||
valid = form.is_valid()
|
|
||||||
|
|
||||||
data = {
|
|
||||||
'form_valid': valid,
|
|
||||||
}
|
|
||||||
|
|
||||||
if valid:
|
|
||||||
# Create the new part variant
|
|
||||||
part = form.save(commit=False)
|
|
||||||
part.variant_of = part_template
|
|
||||||
part.is_template = False
|
|
||||||
|
|
||||||
part.save()
|
|
||||||
|
|
||||||
data['pk'] = part.pk
|
|
||||||
data['text'] = str(part)
|
|
||||||
data['url'] = part.get_absolute_url()
|
|
||||||
|
|
||||||
bom_copy = str2bool(request.POST.get('bom_copy', False))
|
|
||||||
parameters_copy = str2bool(request.POST.get('parameters_copy', False))
|
|
||||||
|
|
||||||
# Copy relevent information from the template part
|
|
||||||
part.deep_copy(part_template, bom=bom_copy, parameters=parameters_copy)
|
|
||||||
|
|
||||||
return self.renderJsonResponse(request, form, data, context=context)
|
|
||||||
|
|
||||||
def get_initial(self):
|
|
||||||
|
|
||||||
part_template = self.get_part_template()
|
|
||||||
|
|
||||||
initials = model_to_dict(part_template)
|
|
||||||
initials['is_template'] = False
|
|
||||||
initials['variant_of'] = part_template
|
|
||||||
initials['bom_copy'] = InvenTreeSetting.get_setting('PART_COPY_BOM')
|
|
||||||
initials['parameters_copy'] = InvenTreeSetting.get_setting('PART_COPY_PARAMETERS')
|
|
||||||
|
|
||||||
return initials
|
|
||||||
|
|
||||||
|
|
||||||
class PartDuplicate(AjaxCreateView):
|
|
||||||
""" View for duplicating an existing Part object.
|
|
||||||
|
|
||||||
- Part <pk> is provided in the URL '/part/<pk>/copy/'
|
|
||||||
- Option for 'deep-copy' which will duplicate all BOM items (default = True)
|
|
||||||
"""
|
|
||||||
|
|
||||||
model = Part
|
|
||||||
form_class = part_forms.EditPartForm
|
|
||||||
|
|
||||||
ajax_form_title = _("Duplicate Part")
|
|
||||||
ajax_template_name = "part/copy_part.html"
|
|
||||||
|
|
||||||
def get_data(self):
|
|
||||||
return {
|
|
||||||
'success': _('Copied part')
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_part_to_copy(self):
|
|
||||||
try:
|
|
||||||
return Part.objects.get(id=self.kwargs['pk'])
|
|
||||||
except (Part.DoesNotExist, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_context_data(self):
|
|
||||||
return {
|
|
||||||
'part': self.get_part_to_copy()
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_form(self):
|
|
||||||
form = super(AjaxCreateView, self).get_form()
|
|
||||||
|
|
||||||
# Force display of the 'bom_copy' widget
|
|
||||||
form.fields['bom_copy'].widget = CheckboxInput()
|
|
||||||
|
|
||||||
# Force display of the 'parameters_copy' widget
|
|
||||||
form.fields['parameters_copy'].widget = CheckboxInput()
|
|
||||||
|
|
||||||
return form
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
|
||||||
""" Capture the POST request for part duplication
|
|
||||||
|
|
||||||
- If the bom_copy object is set, copy all the BOM items too!
|
|
||||||
- If the parameters_copy object is set, copy all the parameters too!
|
|
||||||
"""
|
|
||||||
|
|
||||||
form = self.get_form()
|
|
||||||
|
|
||||||
context = self.get_context_data()
|
|
||||||
|
|
||||||
valid = form.is_valid()
|
|
||||||
|
|
||||||
name = request.POST.get('name', None)
|
|
||||||
|
|
||||||
if name:
|
|
||||||
matches = match_part_names(name)
|
|
||||||
|
|
||||||
if len(matches) > 0:
|
|
||||||
# Display the first five closest matches
|
|
||||||
context['matches'] = matches[:5]
|
|
||||||
|
|
||||||
# Enforce display of the checkbox
|
|
||||||
form.fields['confirm_creation'].widget = CheckboxInput()
|
|
||||||
|
|
||||||
# Check if the user has checked the 'confirm_creation' input
|
|
||||||
confirmed = str2bool(request.POST.get('confirm_creation', False))
|
|
||||||
|
|
||||||
if not confirmed:
|
|
||||||
msg = _('Possible matches exist - confirm creation of new part')
|
|
||||||
form.add_error('confirm_creation', msg)
|
|
||||||
form.pre_form_warning = msg
|
|
||||||
valid = False
|
|
||||||
|
|
||||||
data = {
|
|
||||||
'form_valid': valid
|
|
||||||
}
|
|
||||||
|
|
||||||
if valid:
|
|
||||||
# Create the new Part
|
|
||||||
part = form.save(commit=False)
|
|
||||||
|
|
||||||
part.creation_user = request.user
|
|
||||||
part.save()
|
|
||||||
|
|
||||||
data['pk'] = part.pk
|
|
||||||
data['text'] = str(part)
|
|
||||||
|
|
||||||
bom_copy = str2bool(request.POST.get('bom_copy', False))
|
|
||||||
parameters_copy = str2bool(request.POST.get('parameters_copy', False))
|
|
||||||
|
|
||||||
original = self.get_part_to_copy()
|
|
||||||
|
|
||||||
if original:
|
|
||||||
part.deep_copy(original, bom=bom_copy, parameters=parameters_copy)
|
|
||||||
|
|
||||||
try:
|
|
||||||
data['url'] = part.get_absolute_url()
|
|
||||||
except AttributeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if valid:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return self.renderJsonResponse(request, form, data, context=context)
|
|
||||||
|
|
||||||
def get_initial(self):
|
|
||||||
""" Get initial data based on the Part to be copied from.
|
|
||||||
"""
|
|
||||||
|
|
||||||
part = self.get_part_to_copy()
|
|
||||||
|
|
||||||
if part:
|
|
||||||
initials = model_to_dict(part)
|
|
||||||
else:
|
|
||||||
initials = super(AjaxCreateView, self).get_initial()
|
|
||||||
|
|
||||||
initials['bom_copy'] = str2bool(InvenTreeSetting.get_setting('PART_COPY_BOM', True))
|
|
||||||
|
|
||||||
initials['parameters_copy'] = str2bool(InvenTreeSetting.get_setting('PART_COPY_PARAMETERS', True))
|
|
||||||
|
|
||||||
return initials
|
|
||||||
|
|
||||||
|
|
||||||
class PartCreate(AjaxCreateView):
|
|
||||||
""" View for creating a new Part object.
|
|
||||||
|
|
||||||
Options for providing initial conditions:
|
|
||||||
|
|
||||||
- Provide a category object as initial data
|
|
||||||
"""
|
|
||||||
model = Part
|
|
||||||
form_class = part_forms.EditPartForm
|
|
||||||
|
|
||||||
ajax_form_title = _('Create New Part')
|
|
||||||
ajax_template_name = 'part/create_part.html'
|
|
||||||
|
|
||||||
def get_data(self):
|
|
||||||
return {
|
|
||||||
'success': _("Created new part"),
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_category_id(self):
|
|
||||||
return self.request.GET.get('category', None)
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
|
||||||
""" Provide extra context information for the form to display:
|
|
||||||
|
|
||||||
- Add category information (if provided)
|
|
||||||
"""
|
|
||||||
context = super(PartCreate, self).get_context_data(**kwargs)
|
|
||||||
|
|
||||||
# Add category information to the page
|
|
||||||
cat_id = self.get_category_id()
|
|
||||||
|
|
||||||
if cat_id:
|
|
||||||
try:
|
|
||||||
context['category'] = PartCategory.objects.get(pk=cat_id)
|
|
||||||
except (PartCategory.DoesNotExist, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
return context
|
|
||||||
|
|
||||||
def get_form(self):
|
|
||||||
""" Create Form for making new Part object.
|
|
||||||
Remove the 'default_supplier' field as there are not yet any matching SupplierPart objects
|
|
||||||
"""
|
|
||||||
form = super(AjaxCreateView, self).get_form()
|
|
||||||
|
|
||||||
# Hide the "default expiry" field if the feature is not enabled
|
|
||||||
if not inventree_settings.stock_expiry_enabled():
|
|
||||||
form.fields['default_expiry'].widget = HiddenInput()
|
|
||||||
|
|
||||||
# Hide the "initial stock amount" field if the feature is not enabled
|
|
||||||
if not InvenTreeSetting.get_setting('PART_CREATE_INITIAL'):
|
|
||||||
form.fields['initial_stock'].widget = HiddenInput()
|
|
||||||
|
|
||||||
# Hide the default_supplier field (there are no matching supplier parts yet!)
|
|
||||||
form.fields['default_supplier'].widget = HiddenInput()
|
|
||||||
|
|
||||||
# Display category templates widgets
|
|
||||||
form.fields['selected_category_templates'].widget = CheckboxInput()
|
|
||||||
form.fields['parent_category_templates'].widget = CheckboxInput()
|
|
||||||
|
|
||||||
return form
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
|
||||||
|
|
||||||
form = self.get_form()
|
|
||||||
|
|
||||||
context = {}
|
|
||||||
|
|
||||||
valid = form.is_valid()
|
|
||||||
|
|
||||||
name = request.POST.get('name', None)
|
|
||||||
|
|
||||||
if name:
|
|
||||||
matches = match_part_names(name)
|
|
||||||
|
|
||||||
if len(matches) > 0:
|
|
||||||
|
|
||||||
# Limit to the top 5 matches (to prevent clutter)
|
|
||||||
context['matches'] = matches[:5]
|
|
||||||
|
|
||||||
# Enforce display of the checkbox
|
|
||||||
form.fields['confirm_creation'].widget = CheckboxInput()
|
|
||||||
|
|
||||||
# Check if the user has checked the 'confirm_creation' input
|
|
||||||
confirmed = str2bool(request.POST.get('confirm_creation', False))
|
|
||||||
|
|
||||||
if not confirmed:
|
|
||||||
msg = _('Possible matches exist - confirm creation of new part')
|
|
||||||
form.add_error('confirm_creation', msg)
|
|
||||||
|
|
||||||
form.pre_form_warning = msg
|
|
||||||
valid = False
|
|
||||||
|
|
||||||
data = {
|
|
||||||
'form_valid': valid
|
|
||||||
}
|
|
||||||
|
|
||||||
if valid:
|
|
||||||
# Create the new Part
|
|
||||||
part = form.save(commit=False)
|
|
||||||
|
|
||||||
# Record the user who created this part
|
|
||||||
part.creation_user = request.user
|
|
||||||
|
|
||||||
# Store category templates settings
|
|
||||||
add_category_templates = {
|
|
||||||
'main': form.cleaned_data['selected_category_templates'],
|
|
||||||
'parent': form.cleaned_data['parent_category_templates'],
|
|
||||||
}
|
|
||||||
|
|
||||||
# Save part and pass category template settings
|
|
||||||
part.save(**{'add_category_templates': add_category_templates})
|
|
||||||
|
|
||||||
# Add stock if set
|
|
||||||
init_stock = int(request.POST.get('initial_stock', 0))
|
|
||||||
if init_stock:
|
|
||||||
stock = StockItem(part=part,
|
|
||||||
quantity=init_stock,
|
|
||||||
location=part.default_location)
|
|
||||||
stock.save()
|
|
||||||
|
|
||||||
data['pk'] = part.pk
|
|
||||||
data['text'] = str(part)
|
|
||||||
|
|
||||||
try:
|
|
||||||
data['url'] = part.get_absolute_url()
|
|
||||||
except AttributeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return self.renderJsonResponse(request, form, data, context=context)
|
|
||||||
|
|
||||||
def get_initial(self):
|
|
||||||
""" Get initial data for the new Part object:
|
|
||||||
|
|
||||||
- If a category is provided, pre-fill the Category field
|
|
||||||
"""
|
|
||||||
|
|
||||||
initials = super(PartCreate, self).get_initial()
|
|
||||||
|
|
||||||
if self.get_category_id():
|
|
||||||
try:
|
|
||||||
category = PartCategory.objects.get(pk=self.get_category_id())
|
|
||||||
initials['category'] = category
|
|
||||||
initials['keywords'] = category.default_keywords
|
|
||||||
except (PartCategory.DoesNotExist, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Allow initial data to be passed through as arguments
|
|
||||||
for label in ['name', 'IPN', 'description', 'revision', 'keywords']:
|
|
||||||
if label in self.request.GET:
|
|
||||||
initials[label] = self.request.GET.get(label)
|
|
||||||
|
|
||||||
# Automatically create part parameters from category templates
|
|
||||||
initials['selected_category_templates'] = str2bool(InvenTreeSetting.get_setting('PART_CATEGORY_PARAMETERS', False))
|
|
||||||
initials['parent_category_templates'] = initials['selected_category_templates']
|
|
||||||
|
|
||||||
return initials
|
|
||||||
|
|
||||||
|
|
||||||
class PartImport(FileManagementFormView):
|
class PartImport(FileManagementFormView):
|
||||||
''' Part: Upload file, match to fields and import parts(using multi-Step form) '''
|
''' Part: Upload file, match to fields and import parts(using multi-Step form) '''
|
||||||
permission_required = 'part.add'
|
permission_required = 'part.add'
|
||||||
|
@ -254,10 +254,13 @@ class ReportPrintMixin:
|
|||||||
else:
|
else:
|
||||||
pdf = outputs[0].get_document().write_pdf()
|
pdf = outputs[0].get_document().write_pdf()
|
||||||
|
|
||||||
|
inline = common.models.InvenTreeUserSetting.get_setting('REPORT_INLINE', user=request.user)
|
||||||
|
|
||||||
return InvenTree.helpers.DownloadFile(
|
return InvenTree.helpers.DownloadFile(
|
||||||
pdf,
|
pdf,
|
||||||
report_name,
|
report_name,
|
||||||
content_type='application/pdf'
|
content_type='application/pdf',
|
||||||
|
inline=inline,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -79,7 +79,7 @@ content: "v{{report_revision}} - {{ date.isoformat }}";
|
|||||||
|
|
||||||
{% block header_content %}
|
{% block header_content %}
|
||||||
<!-- TODO - Make the company logo asset generic -->
|
<!-- TODO - Make the company logo asset generic -->
|
||||||
<img class='logo' src="{% asset 'company_logo.png' %}" alt="hello" width="150">
|
<img class='logo' src="{% asset 'company_logo.png' %}" alt="logo" width="150">
|
||||||
|
|
||||||
<div class='header-right'>
|
<div class='header-right'>
|
||||||
<h3>
|
<h3>
|
||||||
|
@ -3,6 +3,7 @@ This script calculates translation coverage for various languages
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
def calculate_coverage(filename):
|
def calculate_coverage(filename):
|
||||||
@ -36,8 +37,10 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
MY_DIR = os.path.dirname(os.path.realpath(__file__))
|
MY_DIR = os.path.dirname(os.path.realpath(__file__))
|
||||||
LC_DIR = os.path.abspath(os.path.join(MY_DIR, '..', 'locale'))
|
LC_DIR = os.path.abspath(os.path.join(MY_DIR, '..', 'locale'))
|
||||||
|
STAT_FILE = os.path.abspath(os.path.join(MY_DIR, '..', 'InvenTree/locale_stats.json'))
|
||||||
|
|
||||||
locales = {}
|
locales = {}
|
||||||
|
locales_perc = {}
|
||||||
|
|
||||||
print("InvenTree translation coverage:")
|
print("InvenTree translation coverage:")
|
||||||
|
|
||||||
@ -64,5 +67,10 @@ if __name__ == '__main__':
|
|||||||
percentage = 0
|
percentage = 0
|
||||||
|
|
||||||
print(f"| {locale.ljust(4, ' ')} : {str(percentage).rjust(4, ' ')}% |")
|
print(f"| {locale.ljust(4, ' ')} : {str(percentage).rjust(4, ' ')}% |")
|
||||||
|
locales_perc[locale] = percentage
|
||||||
|
|
||||||
print("-" * 16)
|
print("-" * 16)
|
||||||
|
|
||||||
|
# write locale stats
|
||||||
|
with open(STAT_FILE, 'w') as target:
|
||||||
|
json.dump(locales_perc, target)
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
{% load inventree_extras %}
|
{% load inventree_extras %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load l10n %}
|
||||||
{% load markdownify %}
|
{% load markdownify %}
|
||||||
|
|
||||||
{% block menubar %}
|
{% block menubar %}
|
||||||
@ -152,7 +153,7 @@
|
|||||||
{
|
{
|
||||||
stock_item: {{ item.pk }},
|
stock_item: {{ item.pk }},
|
||||||
part: {{ item.part.pk }},
|
part: {{ item.part.pk }},
|
||||||
quantity: {{ item.quantity }},
|
quantity: {{ item.quantity|unlocalize }},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -395,4 +396,4 @@
|
|||||||
url: "{% url 'api-stock-tracking-list' %}",
|
url: "{% url 'api-stock-tracking-list' %}",
|
||||||
});
|
});
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -30,6 +30,18 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<li class='list-group-item' title='{% trans "Labels" %}'>
|
||||||
|
<a href='#' class='nav-toggle' id='select-user-labels'>
|
||||||
|
<span class='fas fa-tag'></span> {% trans "Labels" %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class='list-group-item' title='{% trans "Reports" %}'>
|
||||||
|
<a href='#' class='nav-toggle' id='select-user-reports'>
|
||||||
|
<span class='fas fa-file-pdf'></span> {% trans "Reports" %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
<li class='list-group-item' title='{% trans "Settings" %}'>
|
<li class='list-group-item' title='{% trans "Settings" %}'>
|
||||||
<a href='#' class='nav-toggle' id='select-user-settings'>
|
<a href='#' class='nav-toggle' id='select-user-settings'>
|
||||||
|
@ -68,80 +68,3 @@
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block js_ready %}
|
|
||||||
{{ block.super }}
|
|
||||||
|
|
||||||
$("#param-table").inventreeTable({
|
|
||||||
url: "{% url 'api-part-parameter-template-list' %}",
|
|
||||||
queryParams: {
|
|
||||||
ordering: 'name',
|
|
||||||
},
|
|
||||||
formatNoMatches: function() { return '{% trans "No part parameter templates found" %}'; },
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
field: 'pk',
|
|
||||||
title: 'ID',
|
|
||||||
visible: false,
|
|
||||||
switchable: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'name',
|
|
||||||
title: 'Name',
|
|
||||||
sortable: 'true',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'units',
|
|
||||||
title: 'Units',
|
|
||||||
sortable: 'true',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
formatter: function(value, row, index, field) {
|
|
||||||
var bEdit = "<button title='{% trans "Edit Template" %}' class='template-edit btn btn-default btn-glyph' type='button' pk='" + row.pk + "'><span class='fas fa-edit'></span></button>";
|
|
||||||
var bDel = "<button title='{% trans "Delete Template" %}' class='template-delete btn btn-default btn-glyph' type='button' pk='" + row.pk + "'><span class='fas fa-trash-alt icon-red'></span></button>";
|
|
||||||
|
|
||||||
var html = "<div class='btn-group float-right' role='group'>" + bEdit + bDel + "</div>";
|
|
||||||
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
$("#new-param").click(function() {
|
|
||||||
launchModalForm("{% url 'part-param-template-create' %}", {
|
|
||||||
success: function() {
|
|
||||||
$("#param-table").bootstrapTable('refresh');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
$("#param-table").on('click', '.template-edit', function() {
|
|
||||||
var button = $(this);
|
|
||||||
|
|
||||||
var url = "/part/parameter/template/" + button.attr('pk') + "/edit/";
|
|
||||||
|
|
||||||
launchModalForm(url, {
|
|
||||||
success: function() {
|
|
||||||
$("#param-table").bootstrapTable('refresh');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
$("#param-table").on('click', '.template-delete', function() {
|
|
||||||
var button = $(this);
|
|
||||||
|
|
||||||
var url = "/part/parameter/template/" + button.attr('pk') + "/delete/";
|
|
||||||
|
|
||||||
launchModalForm(url, {
|
|
||||||
success: function() {
|
|
||||||
$("#param-table").bootstrapTable('refresh');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
$("#import-part").click(function() {
|
|
||||||
launchModalForm("{% url 'api-part-import' %}?reset", {});
|
|
||||||
});
|
|
||||||
|
|
||||||
{% endblock %}
|
|
@ -18,6 +18,8 @@
|
|||||||
{% include "InvenTree/settings/user_settings.html" %}
|
{% include "InvenTree/settings/user_settings.html" %}
|
||||||
{% include "InvenTree/settings/user_homepage.html" %}
|
{% include "InvenTree/settings/user_homepage.html" %}
|
||||||
{% include "InvenTree/settings/user_search.html" %}
|
{% include "InvenTree/settings/user_search.html" %}
|
||||||
|
{% include "InvenTree/settings/user_labels.html" %}
|
||||||
|
{% include "InvenTree/settings/user_reports.html" %}
|
||||||
|
|
||||||
{% if user.is_staff %}
|
{% if user.is_staff %}
|
||||||
|
|
||||||
@ -241,6 +243,79 @@ $("#cat-param-table").on('click', '.template-delete', function() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$("#param-table").inventreeTable({
|
||||||
|
url: "{% url 'api-part-parameter-template-list' %}",
|
||||||
|
queryParams: {
|
||||||
|
ordering: 'name',
|
||||||
|
},
|
||||||
|
formatNoMatches: function() { return '{% trans "No part parameter templates found" %}'; },
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
field: 'pk',
|
||||||
|
title: 'ID',
|
||||||
|
visible: false,
|
||||||
|
switchable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'name',
|
||||||
|
title: 'Name',
|
||||||
|
sortable: 'true',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'units',
|
||||||
|
title: 'Units',
|
||||||
|
sortable: 'true',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
formatter: function(value, row, index, field) {
|
||||||
|
var bEdit = "<button title='{% trans "Edit Template" %}' class='template-edit btn btn-default btn-glyph' type='button' pk='" + row.pk + "'><span class='fas fa-edit'></span></button>";
|
||||||
|
var bDel = "<button title='{% trans "Delete Template" %}' class='template-delete btn btn-default btn-glyph' type='button' pk='" + row.pk + "'><span class='fas fa-trash-alt icon-red'></span></button>";
|
||||||
|
|
||||||
|
var html = "<div class='btn-group float-right' role='group'>" + bEdit + bDel + "</div>";
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#new-param").click(function() {
|
||||||
|
launchModalForm("{% url 'part-param-template-create' %}", {
|
||||||
|
success: function() {
|
||||||
|
$("#param-table").bootstrapTable('refresh');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#param-table").on('click', '.template-edit', function() {
|
||||||
|
var button = $(this);
|
||||||
|
|
||||||
|
var url = "/part/parameter/template/" + button.attr('pk') + "/edit/";
|
||||||
|
|
||||||
|
launchModalForm(url, {
|
||||||
|
success: function() {
|
||||||
|
$("#param-table").bootstrapTable('refresh');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#param-table").on('click', '.template-delete', function() {
|
||||||
|
var button = $(this);
|
||||||
|
|
||||||
|
var url = "/part/parameter/template/" + button.attr('pk') + "/delete/";
|
||||||
|
|
||||||
|
launchModalForm(url, {
|
||||||
|
success: function() {
|
||||||
|
$("#param-table").bootstrapTable('refresh');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#import-part").click(function() {
|
||||||
|
launchModalForm("{% url 'api-part-import' %}?reset", {});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
enableNavbar({
|
enableNavbar({
|
||||||
label: 'settings',
|
label: 'settings',
|
||||||
toggleId: '#item-menu-toggle',
|
toggleId: '#item-menu-toggle',
|
||||||
|
@ -71,25 +71,38 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<form action="{% url 'set_language' %}" method="post">
|
<div class="col">
|
||||||
{% csrf_token %}
|
<form action="{% url 'set_language' %}" method="post">
|
||||||
<input name="next" type="hidden" value="{% url 'settings' %}">
|
{% csrf_token %}
|
||||||
<div class="col-sm-6" style="width: 200px;"><div id="div_id_language" class="form-group"><div class="controls ">
|
<input name="next" type="hidden" value="{% url 'settings' %}">
|
||||||
<select name="language" class="select form-control">
|
<div class="col-sm-6" style="width: 200px;"><div id="div_id_language" class="form-group"><div class="controls ">
|
||||||
{% get_current_language as LANGUAGE_CODE %}
|
<select name="language" class="select form-control">
|
||||||
{% get_available_languages as LANGUAGES %}
|
{% get_current_language as LANGUAGE_CODE %}
|
||||||
{% get_language_info_list for LANGUAGES as languages %}
|
{% get_available_languages as LANGUAGES %}
|
||||||
{% for language in languages %}
|
{% get_language_info_list for LANGUAGES as languages %}
|
||||||
<option value="{{ language.code }}"{% if language.code == LANGUAGE_CODE %} selected{% endif %}>
|
{% for language in languages %}
|
||||||
{{ language.name_local }} ({{ language.code }})
|
{% define language.code as lang_code %}
|
||||||
</option>
|
{% define locale_stats|keyvalue:lang_code as lang_translated %}
|
||||||
{% endfor %}
|
<option value="{{ lang_code }}"{% if lang_code == LANGUAGE_CODE %} selected{% endif %}>
|
||||||
</select>
|
{{ language.name_local }} ({{ lang_code }})
|
||||||
</div></div></div>
|
{% if lang_translated %}
|
||||||
<div class="col-sm-6" style="width: auto;">
|
{% blocktrans %}{{ lang_translated }}% translated{% endblocktrans %}
|
||||||
<input type="submit" value="{% trans 'Set Language' %}" class="btn btn btn-primary">
|
{% else %}
|
||||||
</div>
|
{% trans 'No translations available' %}
|
||||||
</form>
|
{% endif %}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div></div></div>
|
||||||
|
<div class="col-sm-6" style="width: auto;">
|
||||||
|
<input type="submit" value="{% trans 'Set Language' %}" class="btn btn btn-primary">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-6">
|
||||||
|
<h4>{% trans "Help the translation efforts!" %}</h4>
|
||||||
|
<p>{% blocktrans with link="https://crowdin.com/project/inventree" %}Native language translation of the InvenTree web application is <a href="{{link}}">community contributed via crowdin</a>. Contributions are welcomed and encouraged.{% endblocktrans %}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
23
InvenTree/templates/InvenTree/settings/user_labels.html
Normal file
23
InvenTree/templates/InvenTree/settings/user_labels.html
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{% extends "panel.html" %}
|
||||||
|
|
||||||
|
{% load i18n %}
|
||||||
|
{% load inventree_extras %}
|
||||||
|
|
||||||
|
{% block label %}user-labels{% endblock %}
|
||||||
|
|
||||||
|
{% block heading %}
|
||||||
|
{% trans "Label Settings" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class='row'>
|
||||||
|
<table class='table table-striped table-condensed'>
|
||||||
|
{% include "InvenTree/settings/header.html" %}
|
||||||
|
<tbody>
|
||||||
|
{% include "InvenTree/settings/setting.html" with key="LABEL_INLINE" icon='fa-tag' user_setting=True %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
23
InvenTree/templates/InvenTree/settings/user_reports.html
Normal file
23
InvenTree/templates/InvenTree/settings/user_reports.html
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{% extends "panel.html" %}
|
||||||
|
|
||||||
|
{% load i18n %}
|
||||||
|
{% load inventree_extras %}
|
||||||
|
|
||||||
|
{% block label %}user-reports{% endblock %}
|
||||||
|
|
||||||
|
{% block heading %}
|
||||||
|
{% trans "Report Settings" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class='row'>
|
||||||
|
<table class='table table-striped table-condensed'>
|
||||||
|
{% include "InvenTree/settings/header.html" %}
|
||||||
|
<tbody>
|
||||||
|
{% include "InvenTree/settings/setting.html" with key="REPORT_INLINE" icon='fa-file-pdf' user_setting=True %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
@ -927,7 +927,7 @@ function loadBuildTable(table, options) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'responsible',
|
field: 'responsible',
|
||||||
title: '{% trans "Resposible" %}',
|
title: '{% trans "Responsible" %}',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
formatter: function(value, row, index, field) {
|
formatter: function(value, row, index, field) {
|
||||||
if (value)
|
if (value)
|
||||||
|
@ -30,6 +30,17 @@ function createManufacturerPart(options={}) {
|
|||||||
fields.manufacturer.value = options.manufacturer;
|
fields.manufacturer.value = options.manufacturer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fields.manufacturer.secondary = {
|
||||||
|
title: '{% trans "Add Manufacturer" %}',
|
||||||
|
fields: function(data) {
|
||||||
|
var company_fields = companyFormFields();
|
||||||
|
|
||||||
|
company_fields.is_manufacturer.value = true;
|
||||||
|
|
||||||
|
return company_fields;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
constructForm('{% url "api-manufacturer-part-list" %}', {
|
constructForm('{% url "api-manufacturer-part-list" %}', {
|
||||||
fields: fields,
|
fields: fields,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -72,7 +83,7 @@ function supplierPartFields() {
|
|||||||
filters: {
|
filters: {
|
||||||
part_detail: true,
|
part_detail: true,
|
||||||
manufacturer_detail: true,
|
manufacturer_detail: true,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
description: {},
|
description: {},
|
||||||
link: {
|
link: {
|
||||||
@ -108,6 +119,33 @@ function createSupplierPart(options={}) {
|
|||||||
fields.manufacturer_part.value = options.manufacturer_part;
|
fields.manufacturer_part.value = options.manufacturer_part;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add a secondary modal for the supplier
|
||||||
|
fields.supplier.secondary = {
|
||||||
|
title: '{% trans "Add Supplier" %}',
|
||||||
|
fields: function(data) {
|
||||||
|
var company_fields = companyFormFields();
|
||||||
|
|
||||||
|
company_fields.is_supplier.value = true;
|
||||||
|
|
||||||
|
return company_fields;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add a secondary modal for the manufacturer part
|
||||||
|
fields.manufacturer_part.secondary = {
|
||||||
|
title: '{% trans "Add Manufacturer Part" %}',
|
||||||
|
fields: function(data) {
|
||||||
|
var mp_fields = manufacturerPartFields();
|
||||||
|
|
||||||
|
if (data.part) {
|
||||||
|
mp_fields.part.value = data.part;
|
||||||
|
mp_fields.part.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mp_fields;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
constructForm('{% url "api-supplier-part-list" %}', {
|
constructForm('{% url "api-supplier-part-list" %}', {
|
||||||
fields: fields,
|
fields: fields,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
@ -240,6 +240,7 @@ function constructDeleteForm(fields, options) {
|
|||||||
* - hidden: Set to true to hide the field
|
* - hidden: Set to true to hide the field
|
||||||
* - icon: font-awesome icon to display before the field
|
* - icon: font-awesome icon to display before the field
|
||||||
* - prefix: Custom HTML prefix to display before the field
|
* - prefix: Custom HTML prefix to display before the field
|
||||||
|
* - data: map of data to fill out field values with
|
||||||
* - focus: Name of field to focus on when modal is displayed
|
* - focus: Name of field to focus on when modal is displayed
|
||||||
* - preventClose: Set to true to prevent form from closing on success
|
* - preventClose: Set to true to prevent form from closing on success
|
||||||
* - onSuccess: callback function when form action is successful
|
* - onSuccess: callback function when form action is successful
|
||||||
@ -263,6 +264,11 @@ function constructForm(url, options) {
|
|||||||
// Default HTTP method
|
// Default HTTP method
|
||||||
options.method = options.method || 'PATCH';
|
options.method = options.method || 'PATCH';
|
||||||
|
|
||||||
|
// Construct an "empty" data object if not provided
|
||||||
|
if (!options.data) {
|
||||||
|
options.data = {};
|
||||||
|
}
|
||||||
|
|
||||||
// Request OPTIONS endpoint from the API
|
// Request OPTIONS endpoint from the API
|
||||||
getApiEndpointOptions(url, function(OPTIONS) {
|
getApiEndpointOptions(url, function(OPTIONS) {
|
||||||
|
|
||||||
@ -346,10 +352,19 @@ function constructFormBody(fields, options) {
|
|||||||
// otherwise *all* fields will be displayed
|
// otherwise *all* fields will be displayed
|
||||||
var displayed_fields = options.fields || fields;
|
var displayed_fields = options.fields || fields;
|
||||||
|
|
||||||
|
// Handle initial data overrides
|
||||||
|
if (options.data) {
|
||||||
|
for (const field in options.data) {
|
||||||
|
|
||||||
|
if (field in fields) {
|
||||||
|
fields[field].value = options.data[field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Provide each field object with its own name
|
// Provide each field object with its own name
|
||||||
for(field in fields) {
|
for(field in fields) {
|
||||||
fields[field].name = field;
|
fields[field].name = field;
|
||||||
|
|
||||||
|
|
||||||
// If any "instance_filters" are defined for the endpoint, copy them across (overwrite)
|
// If any "instance_filters" are defined for the endpoint, copy them across (overwrite)
|
||||||
if (fields[field].instance_filters) {
|
if (fields[field].instance_filters) {
|
||||||
@ -366,6 +381,10 @@ function constructFormBody(fields, options) {
|
|||||||
|
|
||||||
// TODO: Refactor the following code with Object.assign (see above)
|
// TODO: Refactor the following code with Object.assign (see above)
|
||||||
|
|
||||||
|
// "before" and "after" renders
|
||||||
|
fields[field].before = field_options.before;
|
||||||
|
fields[field].after = field_options.after;
|
||||||
|
|
||||||
// Secondary modal options
|
// Secondary modal options
|
||||||
fields[field].secondary = field_options.secondary;
|
fields[field].secondary = field_options.secondary;
|
||||||
|
|
||||||
@ -545,6 +564,30 @@ function insertConfirmButton(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Extract all specified form values as a single object
|
||||||
|
*/
|
||||||
|
function extractFormData(fields, options) {
|
||||||
|
|
||||||
|
var data = {};
|
||||||
|
|
||||||
|
for (var idx = 0; idx < options.field_names.length; idx++) {
|
||||||
|
|
||||||
|
var name = options.field_names[idx];
|
||||||
|
|
||||||
|
var field = fields[name] || null;
|
||||||
|
|
||||||
|
if (!field) continue;
|
||||||
|
|
||||||
|
if (field.type == 'candy') continue;
|
||||||
|
|
||||||
|
data[name] = getFormFieldValue(name, field, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Submit form data to the server.
|
* Submit form data to the server.
|
||||||
*
|
*
|
||||||
@ -560,10 +603,15 @@ function submitFormData(fields, options) {
|
|||||||
var has_files = false;
|
var has_files = false;
|
||||||
|
|
||||||
// Extract values for each field
|
// Extract values for each field
|
||||||
options.field_names.forEach(function(name) {
|
for (var idx = 0; idx < options.field_names.length; idx++) {
|
||||||
|
|
||||||
|
var name = options.field_names[idx];
|
||||||
|
|
||||||
var field = fields[name] || null;
|
var field = fields[name] || null;
|
||||||
|
|
||||||
|
// Ignore visual fields
|
||||||
|
if (field && field.type == 'candy') continue;
|
||||||
|
|
||||||
if (field) {
|
if (field) {
|
||||||
|
|
||||||
var value = getFormFieldValue(name, field, options);
|
var value = getFormFieldValue(name, field, options);
|
||||||
@ -593,7 +641,7 @@ function submitFormData(fields, options) {
|
|||||||
} else {
|
} else {
|
||||||
console.log(`WARNING: Could not find field matching '${name}'`);
|
console.log(`WARNING: Could not find field matching '${name}'`);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
var upload_func = inventreePut;
|
var upload_func = inventreePut;
|
||||||
|
|
||||||
@ -926,10 +974,10 @@ function initializeRelatedFields(fields, options) {
|
|||||||
|
|
||||||
switch (field.type) {
|
switch (field.type) {
|
||||||
case 'related field':
|
case 'related field':
|
||||||
initializeRelatedField(name, field, options);
|
initializeRelatedField(field, fields, options);
|
||||||
break;
|
break;
|
||||||
case 'choice':
|
case 'choice':
|
||||||
initializeChoiceField(name, field, options);
|
initializeChoiceField(field, fields, options);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -944,7 +992,9 @@ function initializeRelatedFields(fields, options) {
|
|||||||
* - field: The field data object
|
* - field: The field data object
|
||||||
* - options: The options object provided by the client
|
* - options: The options object provided by the client
|
||||||
*/
|
*/
|
||||||
function addSecondaryModal(name, field, options) {
|
function addSecondaryModal(field, fields, options) {
|
||||||
|
|
||||||
|
var name = field.name;
|
||||||
|
|
||||||
var secondary = field.secondary;
|
var secondary = field.secondary;
|
||||||
|
|
||||||
@ -957,22 +1007,42 @@ function addSecondaryModal(name, field, options) {
|
|||||||
|
|
||||||
$(options.modal).find(`label[for="id_${name}"]`).append(html);
|
$(options.modal).find(`label[for="id_${name}"]`).append(html);
|
||||||
|
|
||||||
// TODO: Launch a callback
|
// Callback function when the secondary button is pressed
|
||||||
$(options.modal).find(`#btn-new-${name}`).click(function() {
|
$(options.modal).find(`#btn-new-${name}`).click(function() {
|
||||||
|
|
||||||
if (secondary.callback) {
|
// Determine the API query URL
|
||||||
// A "custom" callback can be specified for the button
|
var url = secondary.api_url || field.api_url;
|
||||||
secondary.callback(field, options);
|
|
||||||
} else if (secondary.api_url) {
|
|
||||||
// By default, a new modal form is created, with the parameters specified
|
|
||||||
// The parameters match the "normal" form creation parameters
|
|
||||||
|
|
||||||
secondary.onSuccess = function(data, opts) {
|
// If the "fields" attribute is a function, call it with data
|
||||||
setRelatedFieldData(name, data, options);
|
if (secondary.fields instanceof Function) {
|
||||||
};
|
|
||||||
|
|
||||||
constructForm(secondary.api_url, secondary);
|
// Extract form values at time of button press
|
||||||
|
var data = extractFormData(fields, options)
|
||||||
|
|
||||||
|
secondary.fields = secondary.fields(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If no onSuccess function is defined, provide a default one
|
||||||
|
if (!secondary.onSuccess) {
|
||||||
|
secondary.onSuccess = function(data, opts) {
|
||||||
|
|
||||||
|
// Force refresh from the API, to get full detail
|
||||||
|
inventreeGet(`${url}${data.pk}/`, {}, {
|
||||||
|
success: function(responseData) {
|
||||||
|
|
||||||
|
setRelatedFieldData(name, responseData, options);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method should be "POST" for creation
|
||||||
|
secondary.method = secondary.method || 'POST';
|
||||||
|
|
||||||
|
constructForm(
|
||||||
|
url,
|
||||||
|
secondary
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -986,7 +1056,9 @@ function addSecondaryModal(name, field, options) {
|
|||||||
* - field: Field definition from the OPTIONS request
|
* - field: Field definition from the OPTIONS request
|
||||||
* - options: Original options object provided by the client
|
* - options: Original options object provided by the client
|
||||||
*/
|
*/
|
||||||
function initializeRelatedField(name, field, options) {
|
function initializeRelatedField(field, fields, options) {
|
||||||
|
|
||||||
|
var name = field.name;
|
||||||
|
|
||||||
if (!field.api_url) {
|
if (!field.api_url) {
|
||||||
// TODO: Provide manual api_url option?
|
// TODO: Provide manual api_url option?
|
||||||
@ -999,7 +1071,7 @@ function initializeRelatedField(name, field, options) {
|
|||||||
|
|
||||||
// Add a button to launch a 'secondary' modal
|
// Add a button to launch a 'secondary' modal
|
||||||
if (field.secondary != null) {
|
if (field.secondary != null) {
|
||||||
addSecondaryModal(name, field, options);
|
addSecondaryModal(field, fields, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Add 'placeholder' support for entry select2 fields
|
// TODO: Add 'placeholder' support for entry select2 fields
|
||||||
@ -1168,7 +1240,9 @@ function setRelatedFieldData(name, data, options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function initializeChoiceField(name, field, options) {
|
function initializeChoiceField(field, fields, options) {
|
||||||
|
|
||||||
|
var name = field.name;
|
||||||
|
|
||||||
var select = $(options.modal).find(`#id_${name}`);
|
var select = $(options.modal).find(`#id_${name}`);
|
||||||
|
|
||||||
@ -1279,6 +1353,11 @@ function renderModelData(name, model, data, parameters, options) {
|
|||||||
*/
|
*/
|
||||||
function constructField(name, parameters, options) {
|
function constructField(name, parameters, options) {
|
||||||
|
|
||||||
|
// Shortcut for simple visual fields
|
||||||
|
if (parameters.type == 'candy') {
|
||||||
|
return constructCandyInput(name, parameters, options);
|
||||||
|
}
|
||||||
|
|
||||||
var field_name = `id_${name}`;
|
var field_name = `id_${name}`;
|
||||||
|
|
||||||
// Hidden inputs are rendered without label / help text / etc
|
// Hidden inputs are rendered without label / help text / etc
|
||||||
@ -1292,7 +1371,14 @@ function constructField(name, parameters, options) {
|
|||||||
form_classes += ' has-error';
|
form_classes += ' has-error';
|
||||||
}
|
}
|
||||||
|
|
||||||
var html = `<div id='div_${field_name}' class='${form_classes}'>`;
|
var html = '';
|
||||||
|
|
||||||
|
// Optional content to render before the field
|
||||||
|
if (parameters.before) {
|
||||||
|
html += parameters.before;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `<div id='div_${field_name}' class='${form_classes}'>`;
|
||||||
|
|
||||||
// Add a label
|
// Add a label
|
||||||
html += constructLabel(name, parameters);
|
html += constructLabel(name, parameters);
|
||||||
@ -1352,6 +1438,10 @@ function constructField(name, parameters, options) {
|
|||||||
html += `</div>`; // controls
|
html += `</div>`; // controls
|
||||||
html += `</div>`; // form-group
|
html += `</div>`; // form-group
|
||||||
|
|
||||||
|
if (parameters.after) {
|
||||||
|
html += parameters.after;
|
||||||
|
}
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1430,6 +1520,9 @@ function constructInput(name, parameters, options) {
|
|||||||
case 'date':
|
case 'date':
|
||||||
func = constructDateInput;
|
func = constructDateInput;
|
||||||
break;
|
break;
|
||||||
|
case 'candy':
|
||||||
|
func = constructCandyInput;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
// Unsupported field type!
|
// Unsupported field type!
|
||||||
break;
|
break;
|
||||||
@ -1658,6 +1751,17 @@ function constructDateInput(name, parameters, options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Construct a "candy" field input
|
||||||
|
* No actual field data!
|
||||||
|
*/
|
||||||
|
function constructCandyInput(name, parameters, options) {
|
||||||
|
|
||||||
|
return parameters.html;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Construct a 'help text' div based on the field parameters
|
* Construct a 'help text' div based on the field parameters
|
||||||
*
|
*
|
||||||
|
@ -13,6 +13,16 @@ function createSalesOrder(options={}) {
|
|||||||
},
|
},
|
||||||
customer: {
|
customer: {
|
||||||
value: options.customer,
|
value: options.customer,
|
||||||
|
secondary: {
|
||||||
|
title: '{% trans "Add Customer" %}',
|
||||||
|
fields: function(data) {
|
||||||
|
var fields = companyFormFields();
|
||||||
|
|
||||||
|
fields.is_customer.value = true;
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
customer_reference: {},
|
customer_reference: {},
|
||||||
description: {},
|
description: {},
|
||||||
@ -44,6 +54,16 @@ function createPurchaseOrder(options={}) {
|
|||||||
},
|
},
|
||||||
supplier: {
|
supplier: {
|
||||||
value: options.supplier,
|
value: options.supplier,
|
||||||
|
secondary: {
|
||||||
|
title: '{% trans "Add Supplier" %}',
|
||||||
|
fields: function(data) {
|
||||||
|
var fields = companyFormFields();
|
||||||
|
|
||||||
|
fields.is_supplier.value = true;
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
supplier_reference: {},
|
supplier_reference: {},
|
||||||
description: {},
|
description: {},
|
||||||
|
@ -13,6 +13,144 @@ function yesNoLabel(value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Construct fieldset for part forms
|
||||||
|
function partFields(options={}) {
|
||||||
|
|
||||||
|
var fields = {
|
||||||
|
category: {
|
||||||
|
secondary: {
|
||||||
|
title: '{% trans "Add Part Category" %}',
|
||||||
|
fields: function(data) {
|
||||||
|
var fields = categoryFields();
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
name: {},
|
||||||
|
IPN: {},
|
||||||
|
revision: {},
|
||||||
|
description: {},
|
||||||
|
variant_of: {},
|
||||||
|
keywords: {
|
||||||
|
icon: 'fa-key',
|
||||||
|
},
|
||||||
|
units: {},
|
||||||
|
link: {
|
||||||
|
icon: 'fa-link',
|
||||||
|
},
|
||||||
|
default_location: {
|
||||||
|
},
|
||||||
|
default_supplier: {},
|
||||||
|
default_expiry: {
|
||||||
|
icon: 'fa-calendar-alt',
|
||||||
|
},
|
||||||
|
minimum_stock: {
|
||||||
|
icon: 'fa-boxes',
|
||||||
|
},
|
||||||
|
attributes: {
|
||||||
|
type: 'candy',
|
||||||
|
html: `<hr><h4><i>{% trans "Part Attributes" %}</i></h4><hr>`
|
||||||
|
},
|
||||||
|
component: {
|
||||||
|
value: global_settings.PART_COMPONENT,
|
||||||
|
},
|
||||||
|
assembly: {
|
||||||
|
value: global_settings.PART_ASSEMBLY,
|
||||||
|
},
|
||||||
|
is_template: {
|
||||||
|
value: global_settings.PART_TEMPLATE,
|
||||||
|
},
|
||||||
|
trackable: {
|
||||||
|
value: global_settings.PART_TRACKABLE,
|
||||||
|
},
|
||||||
|
purchaseable: {
|
||||||
|
value: global_settings.PART_PURCHASEABLE,
|
||||||
|
},
|
||||||
|
salable: {
|
||||||
|
value: global_settings.PART_SALABLE,
|
||||||
|
},
|
||||||
|
virtual: {
|
||||||
|
value: global_settings.PART_VIRTUAL,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// If editing a part, we can set the "active" status
|
||||||
|
if (options.edit) {
|
||||||
|
fields.active = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pop expiry field
|
||||||
|
if (!global_settings.STOCK_ENABLE_EXPIRY) {
|
||||||
|
delete fields["default_expiry"];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional fields when "creating" a new part
|
||||||
|
if (options.create) {
|
||||||
|
|
||||||
|
// No supplier parts available yet
|
||||||
|
delete fields["default_supplier"];
|
||||||
|
|
||||||
|
fields.create = {
|
||||||
|
type: 'candy',
|
||||||
|
html: `<hr><h4><i>{% trans "Part Creation Options" %}</i></h4><hr>`,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (global_settings.PART_CREATE_INITIAL) {
|
||||||
|
fields.initial_stock = {
|
||||||
|
type: 'decimal',
|
||||||
|
label: '{% trans "Initial Stock Quantity" %}',
|
||||||
|
help_text: '{% trans "Initialize part stock with specified quantity" %}',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.copy_category_parameters = {
|
||||||
|
type: 'boolean',
|
||||||
|
label: '{% trans "Copy Category Parameters" %}',
|
||||||
|
help_text: '{% trans "Copy parameter templates from selected part category" %}',
|
||||||
|
value: global_settings.PART_CATEGORY_PARAMETERS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional fields when "duplicating" a part
|
||||||
|
if (options.duplicate) {
|
||||||
|
|
||||||
|
fields.duplicate = {
|
||||||
|
type: 'candy',
|
||||||
|
html: `<hr><h4><i>{% trans "Part Duplication Options" %}</i></h4><hr>`,
|
||||||
|
};
|
||||||
|
|
||||||
|
fields.copy_from = {
|
||||||
|
type: 'integer',
|
||||||
|
hidden: true,
|
||||||
|
value: options.duplicate,
|
||||||
|
},
|
||||||
|
|
||||||
|
fields.copy_image = {
|
||||||
|
type: 'boolean',
|
||||||
|
label: '{% trans "Copy Image" %}',
|
||||||
|
help_text: '{% trans "Copy image from original part" %}',
|
||||||
|
value: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
fields.copy_bom = {
|
||||||
|
type: 'boolean',
|
||||||
|
label: '{% trans "Copy BOM" %}',
|
||||||
|
help_text: '{% trans "Copy bill of materials from original part" %}',
|
||||||
|
value: global_settings.PART_COPY_BOM,
|
||||||
|
};
|
||||||
|
|
||||||
|
fields.copy_parameters = {
|
||||||
|
type: 'boolean',
|
||||||
|
label: '{% trans "Copy Parameters" %}',
|
||||||
|
help_text: '{% trans "Copy parameter data from original part" %}',
|
||||||
|
value: global_settings.PART_COPY_PARAMETERS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function categoryFields() {
|
function categoryFields() {
|
||||||
return {
|
return {
|
||||||
@ -49,86 +187,49 @@ function editPart(pk, options={}) {
|
|||||||
|
|
||||||
var url = `/api/part/${pk}/`;
|
var url = `/api/part/${pk}/`;
|
||||||
|
|
||||||
var fields = {
|
var fields = partFields({
|
||||||
category: {
|
edit: true
|
||||||
/*
|
});
|
||||||
secondary: {
|
|
||||||
label: '{% trans "New Category" %}',
|
|
||||||
title: '{% trans "Create New Part Category" %}',
|
|
||||||
api_url: '{% url "api-part-category-list" %}',
|
|
||||||
method: 'POST',
|
|
||||||
fields: {
|
|
||||||
name: {},
|
|
||||||
description: {},
|
|
||||||
parent: {
|
|
||||||
secondary: {
|
|
||||||
title: '{% trans "New Parent" %}',
|
|
||||||
api_url: '{% url "api-part-category-list" %}',
|
|
||||||
method: 'POST',
|
|
||||||
fields: {
|
|
||||||
name: {},
|
|
||||||
description: {},
|
|
||||||
parent: {},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
*/
|
|
||||||
},
|
|
||||||
name: {
|
|
||||||
placeholder: 'part name',
|
|
||||||
},
|
|
||||||
IPN: {},
|
|
||||||
description: {},
|
|
||||||
revision: {},
|
|
||||||
keywords: {
|
|
||||||
icon: 'fa-key',
|
|
||||||
},
|
|
||||||
variant_of: {},
|
|
||||||
link: {
|
|
||||||
icon: 'fa-link',
|
|
||||||
},
|
|
||||||
default_location: {
|
|
||||||
/*
|
|
||||||
secondary: {
|
|
||||||
label: '{% trans "New Location" %}',
|
|
||||||
title: '{% trans "Create new stock location" %}',
|
|
||||||
},
|
|
||||||
*/
|
|
||||||
},
|
|
||||||
default_supplier: {
|
|
||||||
filters: {
|
|
||||||
part: pk,
|
|
||||||
part_detail: true,
|
|
||||||
manufacturer_detail: true,
|
|
||||||
supplier_detail: true,
|
|
||||||
},
|
|
||||||
/*
|
|
||||||
secondary: {
|
|
||||||
label: '{% trans "New Supplier Part" %}',
|
|
||||||
title: '{% trans "Create new supplier part" %}',
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
},
|
|
||||||
units: {},
|
|
||||||
minimum_stock: {},
|
|
||||||
virtual: {},
|
|
||||||
is_template: {},
|
|
||||||
assembly: {},
|
|
||||||
component: {},
|
|
||||||
trackable: {},
|
|
||||||
purchaseable: {},
|
|
||||||
salable: {},
|
|
||||||
active: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
constructForm(url, {
|
constructForm(url, {
|
||||||
fields: fields,
|
fields: fields,
|
||||||
title: '{% trans "Edit Part" %}',
|
title: '{% trans "Edit Part" %}',
|
||||||
reload: true,
|
reload: true,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Launch form to duplicate a part
|
||||||
|
function duplicatePart(pk, options={}) {
|
||||||
|
|
||||||
|
// First we need all the part information
|
||||||
|
inventreeGet(`/api/part/${pk}/`, {}, {
|
||||||
|
|
||||||
|
success: function(data) {
|
||||||
|
|
||||||
|
var fields = partFields({
|
||||||
|
duplicate: pk,
|
||||||
|
});
|
||||||
|
|
||||||
|
// If we are making a "variant" part
|
||||||
|
if (options.variant) {
|
||||||
|
|
||||||
|
// Override the "variant_of" field
|
||||||
|
data.variant_of = pk;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructForm('{% url "api-part-list" %}', {
|
||||||
|
method: 'POST',
|
||||||
|
fields: fields,
|
||||||
|
title: '{% trans "Duplicate Part" %}',
|
||||||
|
data: data,
|
||||||
|
onSuccess: function(data) {
|
||||||
|
// Follow the new part
|
||||||
|
location.href = `/part/${data.pk}/`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -1005,6 +1106,7 @@ function loadPriceBreakTable(table, options) {
|
|||||||
formatNoMatches: function() {
|
formatNoMatches: function() {
|
||||||
return `{% trans "No ${human_name} information found" %}`;
|
return `{% trans "No ${human_name} information found" %}`;
|
||||||
},
|
},
|
||||||
|
queryParams: {part: options.part},
|
||||||
url: options.url,
|
url: options.url,
|
||||||
onLoadSuccess: function(tableData) {
|
onLoadSuccess: function(tableData) {
|
||||||
if (linkedGraph) {
|
if (linkedGraph) {
|
||||||
@ -1013,7 +1115,7 @@ function loadPriceBreakTable(table, options) {
|
|||||||
|
|
||||||
// split up for graph definition
|
// split up for graph definition
|
||||||
var graphLabels = Array.from(tableData, x => x.quantity);
|
var graphLabels = Array.from(tableData, x => x.quantity);
|
||||||
var graphData = Array.from(tableData, x => parseFloat(x.price));
|
var graphData = Array.from(tableData, x => x.price);
|
||||||
|
|
||||||
// destroy chart if exists
|
// destroy chart if exists
|
||||||
if (chart){
|
if (chart){
|
||||||
@ -1100,6 +1202,7 @@ function initPriceBreakSet(table, options) {
|
|||||||
human_name: pb_human_name,
|
human_name: pb_human_name,
|
||||||
url: pb_url,
|
url: pb_url,
|
||||||
linkedGraph: linkedGraph,
|
linkedGraph: linkedGraph,
|
||||||
|
part: part_id,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -2,6 +2,18 @@
|
|||||||
{% load inventree_extras %}
|
{% load inventree_extras %}
|
||||||
{% load status_codes %}
|
{% load status_codes %}
|
||||||
|
|
||||||
|
|
||||||
|
function locationFields() {
|
||||||
|
return {
|
||||||
|
parent: {
|
||||||
|
help_text: '{% trans "Parent stock location" %}',
|
||||||
|
},
|
||||||
|
name: {},
|
||||||
|
description: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Stock API functions
|
/* Stock API functions
|
||||||
* Requires api.js to be loaded first
|
* Requires api.js to be loaded first
|
||||||
*/
|
*/
|
||||||
|
@ -24,7 +24,7 @@ However, powerful business logic works in the background to ensure that stock tr
|
|||||||
|
|
||||||
InvenTree is [available via Docker](https://hub.docker.com/r/inventree/inventree). Read the [docker guide](https://inventree.readthedocs.io/en/latest/start/docker/) for full details.
|
InvenTree is [available via Docker](https://hub.docker.com/r/inventree/inventree). Read the [docker guide](https://inventree.readthedocs.io/en/latest/start/docker/) for full details.
|
||||||
|
|
||||||
# Companion App
|
# Mobile App
|
||||||
|
|
||||||
InvenTree is supported by a [companion mobile app](https://inventree.readthedocs.io/en/latest/app/app/) which allows users access to stock control information and functionality.
|
InvenTree is supported by a [companion mobile app](https://inventree.readthedocs.io/en/latest/app/app/) which allows users access to stock control information and functionality.
|
||||||
|
|
||||||
|
38
ci/check_version_number.py
Normal file
38
ci/check_version_number.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
"""
|
||||||
|
On release, ensure that the release tag matches the InvenTree version number!
|
||||||
|
"""
|
||||||
|
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
|
||||||
|
here = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
|
||||||
|
version_file = os.path.join(here, '..', 'InvenTree', 'InvenTree', 'version.py')
|
||||||
|
|
||||||
|
with open(version_file, 'r') as f:
|
||||||
|
|
||||||
|
results = re.findall(r'INVENTREE_SW_VERSION = "(.*)"', f.read())
|
||||||
|
|
||||||
|
if not len(results) == 1:
|
||||||
|
print(f"Could not find INVENTREE_SW_VERSION in {version_file}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
version = results[0]
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('tag', help='Version tag', action='store')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not args.tag == version:
|
||||||
|
print(f"Release tag '{args.tag}' does not match INVENTREE_SW_VERSION '{version}'")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
sys.exit(0)
|
@ -21,7 +21,8 @@ coverage==5.3 # Unit test coverage
|
|||||||
coveralls==2.1.2 # Coveralls linking (for Travis)
|
coveralls==2.1.2 # Coveralls linking (for Travis)
|
||||||
rapidfuzz==0.7.6 # Fuzzy string matching
|
rapidfuzz==0.7.6 # Fuzzy string matching
|
||||||
django-stdimage==5.1.1 # Advanced ImageField management
|
django-stdimage==5.1.1 # Advanced ImageField management
|
||||||
django-weasyprint==1.0.1 # HTML PDF export
|
weasyprint==52.5 # PDF generation library (Note: in the future need to update to 53)
|
||||||
|
django-weasyprint==1.0.1 # django weasyprint integration
|
||||||
django-debug-toolbar==2.2 # Debug / profiling toolbar
|
django-debug-toolbar==2.2 # Debug / profiling toolbar
|
||||||
django-admin-shell==0.1.2 # Python shell for the admin interface
|
django-admin-shell==0.1.2 # Python shell for the admin interface
|
||||||
py-moneyed==0.8.0 # Specific version requirement for py-moneyed
|
py-moneyed==0.8.0 # Specific version requirement for py-moneyed
|
||||||
|
Loading…
x
Reference in New Issue
Block a user