mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +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:
|
||||
- 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
5
.gitignore
vendored
@ -67,4 +67,7 @@ secret_key.txt
|
||||
htmlcov/
|
||||
|
||||
# Development files
|
||||
dev/
|
||||
dev/
|
||||
|
||||
# Locale stats file
|
||||
locale_stats.json
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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
@ -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):
|
||||
|
||||
|
@ -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 """
|
||||
|
||||
|
@ -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 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)
|
||||
|
||||
|
@ -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',
|
||||
]
|
||||
|
||||
|
||||
|
@ -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 %}
|
||||
|
||||
|
@ -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,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
@ -486,12 +486,7 @@
|
||||
|
||||
{% if roles.part.add %}
|
||||
$("#part-duplicate").click(function() {
|
||||
launchModalForm(
|
||||
"{% url 'part-duplicate' part.id %}",
|
||||
{
|
||||
follow: true,
|
||||
}
|
||||
);
|
||||
duplicatePart({{ part.pk }});
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
|
@ -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()
|
||||
|
@ -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(
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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'),
|
||||
|
@ -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'
|
||||
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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)
|
||||
|
@ -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 %}
|
||||
|
@ -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'>
|
||||
|
@ -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 %}
|
@ -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',
|
||||
|
@ -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 %}
|
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',
|
||||
title: '{% trans "Resposible" %}',
|
||||
title: '{% trans "Responsible" %}',
|
||||
sortable: true,
|
||||
formatter: function(value, row, index, field) {
|
||||
if (value)
|
||||
|
@ -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',
|
||||
|
@ -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
|
||||
*
|
||||
|
@ -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: {},
|
||||
|
@ -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,
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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.
|
||||
|
||||
|
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)
|
||||
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
|
||||
|
Loading…
Reference in New Issue
Block a user