Merge branch 'master' of github.com:inventree/InvenTree into exchange_rate_task

This commit is contained in:
eeintech 2021-08-09 10:47:35 -04:00
commit 372d252333
60 changed files with 35820 additions and 32804 deletions

View File

@ -13,6 +13,9 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@v2
- name: Check Release tag
run: |
python3 ci/check_version_number.py ${{ github.event.release.tag_name }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx

5
.gitignore vendored
View File

@ -67,4 +67,7 @@ secret_key.txt
htmlcov/
# Development files
dev/
dev/
# Locale stats file
locale_stats.json

View File

@ -344,13 +344,15 @@ def GetExportFormats():
]
def DownloadFile(data, filename, content_type='application/text'):
""" Create a dynamic file for the user to download.
def DownloadFile(data, filename, content_type='application/text', inline=False):
"""
Create a dynamic file for the user to download.
Args:
data: Raw file data (string or bytes)
filename: Filename for the file download
content_type: Content type for the download
inline: Download "inline" or as attachment? (Default = attachment)
Return:
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['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

View File

@ -85,8 +85,10 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
"""
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 and data is not empty:
@ -193,7 +195,15 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
try:
instance.full_clean()
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

View File

@ -10,11 +10,14 @@ import common.models
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
v9 -> 2021-08-09
- Adds "price_string" to part pricing serializers
v8 -> 2021-07-19
- Refactors the API interface for SupplierPart and ManufacturerPart models
- ManufacturerPart objects can no longer be created via the SupplierPart API endpoint

View File

@ -7,12 +7,15 @@ as JSON objects and passing them to modal forms (using jQuery / bootstrap).
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import os
import json
from django.utils.translation import gettext_lazy as _
from django.template.loader import render_to_string
from django.http import HttpResponse, JsonResponse, HttpResponseRedirect
from django.urls import reverse_lazy
from django.shortcuts import redirect
from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin
@ -800,6 +803,13 @@ class SettingsView(TemplateView):
except:
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

View File

@ -637,7 +637,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'PART_PURCHASEABLE': {
'name': _('Purchaseable'),
'description': _('Parts are purchaseable by default'),
'default': False,
'default': True,
'validator': bool,
},
@ -662,6 +662,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': bool,
},
# TODO: Remove this setting in future, new API forms make this not useful
'PART_SHOW_QUANTITY_IN_FORMS': {
'name': _('Show Quantity in Forms'),
'description': _('Display available part quantity in some forms'),
@ -925,6 +926,20 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'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': {
'name': _('Search Preview Results'),
'description': _('Number of results to show in search preview window'),
@ -964,7 +979,10 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
@classmethod
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):

View File

@ -109,10 +109,13 @@ class LabelPrintMixin:
else:
pdf = outputs[0].get_document().write_pdf()
inline = common.models.InvenTreeUserSetting.get_setting('LABEL_INLINE', user=request.user)
return InvenTree.helpers.DownloadFile(
pdf,
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

View File

@ -23,6 +23,7 @@ from djmoney.money import Money
from djmoney.contrib.exchange.models import convert_money
from djmoney.contrib.exchange.exceptions import MissingRate
from decimal import Decimal
from .models import Part, PartCategory, BomItem
from .models import PartParameter, PartParameterTemplate
@ -30,6 +31,7 @@ from .models import PartAttachment, PartTestTemplate
from .models import PartSellPriceBreak, PartInternalPriceBreak
from .models import PartCategoryParameterTemplate
from stock.models import StockItem
from common.models import InvenTreeSetting
from build.models import Build
@ -628,16 +630,75 @@ class PartList(generics.ListCreateAPIView):
else:
return Response(data)
def perform_create(self, serializer):
def create(self, request, *args, **kwargs):
"""
We wish to save the user who created this part!
Note: Implementation copied from DRF class CreateModelMixin
"""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
part = serializer.save()
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):

View File

@ -177,82 +177,6 @@ class SetPartCategoryForm(forms.Form):
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):
""" Form for editing a PartParameterTemplate object """

View File

@ -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'),
),
]

View File

@ -34,7 +34,6 @@ from stdimage.models import StdImageField
from decimal import Decimal, InvalidOperation
from datetime import datetime
from rapidfuzz import fuzz
import hashlib
from InvenTree import helpers
@ -235,57 +234,6 @@ def rename_part_image(instance, filename):
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):
"""
Defines a custom object manager for the Part model.
@ -409,7 +357,7 @@ class Part(MPTTModel):
"""
# 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:
previous = Part.objects.get(pk=self.pk)
@ -437,39 +385,29 @@ class Part(MPTTModel):
# Get part category
category = self.category
if category and add_category_templates:
# Store templates added to part
if category is not None:
template_list = []
# Create part parameters for selected category
category_templates = add_category_templates['main']
if category_templates:
parent_categories = category.get_ancestors(include_self=True)
for category in parent_categories:
for template in category.get_parameter_templates():
parameter = PartParameter.create(part=self,
template=template.parameter_template,
data=template.default_value,
save=True)
if parameter:
# Check that template wasn't already added
if template.parameter_template not in template_list:
template_list.append(template.parameter_template)
# Create part parameters for parent category
category_templates = add_category_templates['parent']
if category_templates:
# Get parent categories
parent_categories = category.get_ancestors()
for category in parent_categories:
for template in category.get_parameter_templates():
# Check that template wasn't already added
if template.parameter_template not in template_list:
try:
PartParameter.create(part=self,
template=template.parameter_template,
data=template.default_value,
save=True)
except IntegrityError:
# PartParameter already exists
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):
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):
"""
A PartParameterTemplate provides a template for key:value pairs for extra
@ -2243,7 +2191,15 @@ class PartParameterTemplate(models.Model):
except PartParameterTemplate.DoesNotExist:
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)

View File

@ -15,7 +15,8 @@ from djmoney.contrib.django_rest_framework import MoneyField
from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
InvenTreeImageSerializerField,
InvenTreeModelSerializer)
InvenTreeModelSerializer,
InvenTreeMoneySerializer)
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
from stock.models import StockItem
@ -102,7 +103,12 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
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:
model = PartSellPriceBreak
@ -111,6 +117,7 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
'part',
'quantity',
'price',
'price_string',
]
@ -121,7 +128,12 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
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:
model = PartInternalPriceBreak
@ -130,6 +142,7 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
'part',
'quantity',
'price',
'price_string',
]

View File

@ -264,25 +264,25 @@
{% if roles.part.add %}
$("#part-create").click(function() {
launchModalForm(
"{% url 'part-create' %}",
{
follow: true,
data: {
{% if category %}
category: {{ category.id }}
{% endif %}
},
secondary: [
{
field: 'default_location',
label: '{% trans "New Location" %}',
title: '{% trans "Create new Stock Location" %}',
url: "{% url 'stock-location-create' %}",
}
]
}
);
var fields = partFields({
create: true,
});
{% if category %}
fields.category.value = {{ category.pk }};
{% endif %}
constructForm('{% url "api-part-list" %}', {
method: 'POST',
fields: fields,
title: '{% trans "Create Part" %}',
onSuccess: function(data) {
// Follow the new part
location.href = `/part/${data.pk}/`;
},
});
});
{% endif %}

View File

@ -525,10 +525,11 @@
loadPartVariantTable($('#variants-table'), {{ part.pk }});
$('#new-variant').click(function() {
launchModalForm(
"{% url 'make-part-variant' part.id %}",
duplicatePart(
{{ part.pk}},
{
follow: true,
variant: true,
}
);
});

View File

@ -486,12 +486,7 @@
{% if roles.part.add %}
$("#part-duplicate").click(function() {
launchModalForm(
"{% url 'part-duplicate' part.id %}",
{
follow: true,
}
);
duplicatePart({{ part.pk }});
});
{% endif %}

View File

@ -204,6 +204,7 @@ def settings_value(key, *args, **kwargs):
if 'user' in kwargs:
return InvenTreeUserSetting.get_setting(key, user=kwargs['user'])
return InvenTreeSetting.get_setting(key)
@ -269,7 +270,7 @@ def keyvalue(dict, key):
usage:
{% mydict|keyvalue:mykey %}
"""
return dict[key]
return dict.get(key)
@register.simple_tag()

View File

@ -434,8 +434,8 @@ class PartAPITest(InvenTreeAPITestCase):
self.assertTrue(data['active'])
self.assertFalse(data['virtual'])
# By default, parts are not purchaseable
self.assertFalse(data['purchaseable'])
# By default, parts are purchaseable
self.assertTrue(data['purchaseable'])
# Set the default 'purchaseable' status to True
InvenTreeSetting.set_setting(

View File

@ -12,7 +12,7 @@ from django.core.exceptions import ValidationError
import os
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
import part.settings
@ -163,12 +163,6 @@ class PartTest(TestCase):
def test_copy(self):
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):
# check that the sell pricebreaks were loaded
self.assertTrue(self.r1.has_price_breaks)
@ -281,7 +275,7 @@ class PartSettingsTest(TestCase):
"""
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_trackable_default())
@ -293,7 +287,7 @@ class PartSettingsTest(TestCase):
part = self.make_part()
self.assertTrue(part.component)
self.assertFalse(part.purchaseable)
self.assertTrue(part.purchaseable)
self.assertFalse(part.salable)
self.assertFalse(part.trackable)

View File

@ -155,38 +155,6 @@ class PartDetailTest(PartViewTestCase):
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):
def test_valid_create(self):

View File

@ -40,8 +40,7 @@ part_detail_urls = [
url(r'^bom-export/?', views.BomExport.as_view(), name='bom-export'),
url(r'^bom-download/?', views.BomDownload.as_view(), name='bom-download'),
url(r'^validate-bom/', views.BomValidate.as_view(), name='bom-validate'),
url(r'^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'^bom-upload/?', views.BomUpload.as_view(), name='upload-bom'),
@ -81,9 +80,6 @@ category_urls = [
# URL list for part web interface
part_urls = [
# Create a new part
url(r'^new/?', views.PartCreate.as_view(), name='part-create'),
# Upload a part
url(r'^import/', views.PartImport.as_view(), name='part-import'),
url(r'^import-api/', views.PartImportAjax.as_view(), name='api-part-import'),

View File

@ -14,8 +14,7 @@ from django.shortcuts import HttpResponseRedirect
from django.utils.translation import gettext_lazy as _
from django.urls import reverse
from django.views.generic import DetailView, ListView
from django.forms.models import model_to_dict
from django.forms import HiddenInput, CheckboxInput
from django.forms import HiddenInput
from django.conf import settings
from django.contrib import messages
@ -35,7 +34,6 @@ from .models import PartCategory, Part, PartRelated
from .models import PartParameterTemplate
from .models import PartCategoryParameterTemplate
from .models import BomItem
from .models import match_part_names
from .models import PartSellPriceBreak, PartInternalPriceBreak
from common.models import InvenTreeSetting
@ -44,7 +42,7 @@ from common.files import FileManager
from common.views import FileManagementFormView, FileManagementAjaxView
from common.forms import UploadFileForm, MatchFieldForm
from stock.models import StockItem, StockLocation
from stock.models import StockLocation
import common.settings as inventree_settings
@ -233,370 +231,6 @@ class PartSetCategory(AjaxUpdateView):
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):
''' Part: Upload file, match to fields and import parts(using multi-Step form) '''
permission_required = 'part.add'

View File

@ -254,10 +254,13 @@ class ReportPrintMixin:
else:
pdf = outputs[0].get_document().write_pdf()
inline = common.models.InvenTreeUserSetting.get_setting('REPORT_INLINE', user=request.user)
return InvenTree.helpers.DownloadFile(
pdf,
report_name,
content_type='application/pdf'
content_type='application/pdf',
inline=inline,
)

View File

@ -79,7 +79,7 @@ content: "v{{report_revision}} - {{ date.isoformat }}";
{% block header_content %}
<!-- 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'>
<h3>

View File

@ -3,6 +3,7 @@ This script calculates translation coverage for various languages
"""
import os
import json
def calculate_coverage(filename):
@ -36,8 +37,10 @@ if __name__ == '__main__':
MY_DIR = os.path.dirname(os.path.realpath(__file__))
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_perc = {}
print("InvenTree translation coverage:")
@ -64,5 +67,10 @@ if __name__ == '__main__':
percentage = 0
print(f"| {locale.ljust(4, ' ')} : {str(percentage).rjust(4, ' ')}% |")
locales_perc[locale] = percentage
print("-" * 16)
# write locale stats
with open(STAT_FILE, 'w') as target:
json.dump(locales_perc, target)

View File

@ -3,6 +3,7 @@
{% load static %}
{% load inventree_extras %}
{% load i18n %}
{% load l10n %}
{% load markdownify %}
{% block menubar %}
@ -152,7 +153,7 @@
{
stock_item: {{ item.pk }},
part: {{ item.part.pk }},
quantity: {{ item.quantity }},
quantity: {{ item.quantity|unlocalize }},
}
);
@ -395,4 +396,4 @@
url: "{% url 'api-stock-tracking-list' %}",
});
{% endblock %}
{% endblock %}

View File

@ -30,6 +30,18 @@
</a>
</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" %}'>
<a href='#' class='nav-toggle' id='select-user-settings'>

View File

@ -68,80 +68,3 @@
</table>
{% 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 %}

View File

@ -18,6 +18,8 @@
{% include "InvenTree/settings/user_settings.html" %}
{% include "InvenTree/settings/user_homepage.html" %}
{% include "InvenTree/settings/user_search.html" %}
{% include "InvenTree/settings/user_labels.html" %}
{% include "InvenTree/settings/user_reports.html" %}
{% 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({
label: 'settings',
toggleId: '#item-menu-toggle',

View File

@ -71,25 +71,38 @@
</div>
<div class="row">
<form action="{% url 'set_language' %}" method="post">
{% csrf_token %}
<input name="next" type="hidden" value="{% url 'settings' %}">
<div class="col-sm-6" style="width: 200px;"><div id="div_id_language" class="form-group"><div class="controls ">
<select name="language" class="select form-control">
{% get_current_language as LANGUAGE_CODE %}
{% get_available_languages as LANGUAGES %}
{% get_language_info_list for LANGUAGES as languages %}
{% for language in languages %}
<option value="{{ language.code }}"{% if language.code == LANGUAGE_CODE %} selected{% endif %}>
{{ language.name_local }} ({{ language.code }})
</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 class="col">
<form action="{% url 'set_language' %}" method="post">
{% csrf_token %}
<input name="next" type="hidden" value="{% url 'settings' %}">
<div class="col-sm-6" style="width: 200px;"><div id="div_id_language" class="form-group"><div class="controls ">
<select name="language" class="select form-control">
{% get_current_language as LANGUAGE_CODE %}
{% get_available_languages as LANGUAGES %}
{% get_language_info_list for LANGUAGES as languages %}
{% for language in languages %}
{% define language.code as lang_code %}
{% define locale_stats|keyvalue:lang_code as lang_translated %}
<option value="{{ lang_code }}"{% if lang_code == LANGUAGE_CODE %} selected{% endif %}>
{{ language.name_local }} ({{ lang_code }})
{% if lang_translated %}
{% blocktrans %}{{ lang_translated }}% translated{% endblocktrans %}
{% else %}
{% trans 'No translations available' %}
{% 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>
{% endblock %}

View 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 %}

View 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 %}

View File

@ -927,7 +927,7 @@ function loadBuildTable(table, options) {
},
{
field: 'responsible',
title: '{% trans "Resposible" %}',
title: '{% trans "Responsible" %}',
sortable: true,
formatter: function(value, row, index, field) {
if (value)

View File

@ -30,6 +30,17 @@ function createManufacturerPart(options={}) {
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" %}', {
fields: fields,
method: 'POST',
@ -72,7 +83,7 @@ function supplierPartFields() {
filters: {
part_detail: true,
manufacturer_detail: true,
}
},
},
description: {},
link: {
@ -108,6 +119,33 @@ function createSupplierPart(options={}) {
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" %}', {
fields: fields,
method: 'POST',

View File

@ -240,6 +240,7 @@ function constructDeleteForm(fields, options) {
* - hidden: Set to true to hide the field
* - icon: font-awesome icon 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
* - preventClose: Set to true to prevent form from closing on success
* - onSuccess: callback function when form action is successful
@ -263,6 +264,11 @@ function constructForm(url, options) {
// Default HTTP method
options.method = options.method || 'PATCH';
// Construct an "empty" data object if not provided
if (!options.data) {
options.data = {};
}
// Request OPTIONS endpoint from the API
getApiEndpointOptions(url, function(OPTIONS) {
@ -346,10 +352,19 @@ function constructFormBody(fields, options) {
// otherwise *all* fields will be displayed
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
for(field in fields) {
fields[field].name = field;
// If any "instance_filters" are defined for the endpoint, copy them across (overwrite)
if (fields[field].instance_filters) {
@ -366,6 +381,10 @@ function constructFormBody(fields, options) {
// 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
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.
*
@ -560,10 +603,15 @@ function submitFormData(fields, options) {
var has_files = false;
// 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;
// Ignore visual fields
if (field && field.type == 'candy') continue;
if (field) {
var value = getFormFieldValue(name, field, options);
@ -593,7 +641,7 @@ function submitFormData(fields, options) {
} else {
console.log(`WARNING: Could not find field matching '${name}'`);
}
});
}
var upload_func = inventreePut;
@ -926,10 +974,10 @@ function initializeRelatedFields(fields, options) {
switch (field.type) {
case 'related field':
initializeRelatedField(name, field, options);
initializeRelatedField(field, fields, options);
break;
case 'choice':
initializeChoiceField(name, field, options);
initializeChoiceField(field, fields, options);
break;
}
}
@ -944,7 +992,9 @@ function initializeRelatedFields(fields, options) {
* - field: The field data object
* - 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;
@ -957,22 +1007,42 @@ function addSecondaryModal(name, field, options) {
$(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() {
if (secondary.callback) {
// A "custom" callback can be specified for the button
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
// Determine the API query URL
var url = secondary.api_url || field.api_url;
secondary.onSuccess = function(data, opts) {
setRelatedFieldData(name, data, options);
};
// If the "fields" attribute is a function, call it with data
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
* - 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) {
// TODO: Provide manual api_url option?
@ -999,7 +1071,7 @@ function initializeRelatedField(name, field, options) {
// Add a button to launch a 'secondary' modal
if (field.secondary != null) {
addSecondaryModal(name, field, options);
addSecondaryModal(field, fields, options);
}
// 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}`);
@ -1279,6 +1353,11 @@ function renderModelData(name, model, data, 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}`;
// Hidden inputs are rendered without label / help text / etc
@ -1292,7 +1371,14 @@ function constructField(name, parameters, options) {
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
html += constructLabel(name, parameters);
@ -1352,6 +1438,10 @@ function constructField(name, parameters, options) {
html += `</div>`; // controls
html += `</div>`; // form-group
if (parameters.after) {
html += parameters.after;
}
return html;
}
@ -1430,6 +1520,9 @@ function constructInput(name, parameters, options) {
case 'date':
func = constructDateInput;
break;
case 'candy':
func = constructCandyInput;
break;
default:
// Unsupported field type!
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
*

View File

@ -13,6 +13,16 @@ function createSalesOrder(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: {},
description: {},
@ -44,6 +54,16 @@ function createPurchaseOrder(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: {},
description: {},

View File

@ -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() {
return {
@ -49,86 +187,49 @@ function editPart(pk, options={}) {
var url = `/api/part/${pk}/`;
var fields = {
category: {
/*
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: {},
};
var fields = partFields({
edit: true
});
constructForm(url, {
fields: fields,
title: '{% trans "Edit Part" %}',
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() {
return `{% trans "No ${human_name} information found" %}`;
},
queryParams: {part: options.part},
url: options.url,
onLoadSuccess: function(tableData) {
if (linkedGraph) {
@ -1013,7 +1115,7 @@ function loadPriceBreakTable(table, options) {
// split up for graph definition
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
if (chart){
@ -1100,6 +1202,7 @@ function initPriceBreakSet(table, options) {
human_name: pb_human_name,
url: pb_url,
linkedGraph: linkedGraph,
part: part_id,
}
);

View File

@ -2,6 +2,18 @@
{% load inventree_extras %}
{% load status_codes %}
function locationFields() {
return {
parent: {
help_text: '{% trans "Parent stock location" %}',
},
name: {},
description: {},
};
}
/* Stock API functions
* Requires api.js to be loaded first
*/

View File

@ -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.
# 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.

View 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)

View File

@ -21,7 +21,8 @@ coverage==5.3 # Unit test coverage
coveralls==2.1.2 # Coveralls linking (for Travis)
rapidfuzz==0.7.6 # Fuzzy string matching
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-admin-shell==0.1.2 # Python shell for the admin interface
py-moneyed==0.8.0 # Specific version requirement for py-moneyed