Merge branch 'inventree:master' into webhooks

This commit is contained in:
Matthias Mair 2021-11-28 17:25:53 +01:00 committed by GitHub
commit a066f4c940
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
60 changed files with 18993 additions and 17267 deletions

View File

@ -0,0 +1,37 @@
name: Check Translations
on:
push:
branches:
- l10
pull_request:
branches:
- l10
jobs:
check:
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INVENTREE_DB_NAME: './test_db.sqlite'
INVENTREE_DB_ENGINE: django.db.backends.sqlite3
INVENTREE_DEBUG: info
INVENTREE_MEDIA_ROOT: ./media
INVENTREE_STATIC_ROOT: ./static
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Install Dependencies
run: |
sudo apt-get update
sudo apt-get install gettext
pip3 install invoke
invoke install
- name: Test Translations
run: invoke translate
- name: Check Migration Files
run: python3 ci/check_migration_files.py

View File

@ -21,7 +21,8 @@ from django.dispatch import receiver
from mptt.models import MPTTModel, TreeForeignKey from mptt.models import MPTTModel, TreeForeignKey
from mptt.exceptions import InvalidMove from mptt.exceptions import InvalidMove
from .validators import validate_tree_name from InvenTree.fields import InvenTreeURLField
from InvenTree.validators import validate_tree_name
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
@ -89,12 +90,15 @@ class ReferenceIndexingMixin(models.Model):
class InvenTreeAttachment(models.Model): class InvenTreeAttachment(models.Model):
""" Provides an abstracted class for managing file attachments. """ Provides an abstracted class for managing file attachments.
An attachment can be either an uploaded file, or an external URL
Attributes: Attributes:
attachment: File attachment: File
comment: String descriptor for the attachment comment: String descriptor for the attachment
user: User associated with file upload user: User associated with file upload
upload_date: Date the file was uploaded upload_date: Date the file was uploaded
""" """
def getSubdir(self): def getSubdir(self):
""" """
Return the subdirectory under which attachments should be stored. Return the subdirectory under which attachments should be stored.
@ -103,11 +107,32 @@ class InvenTreeAttachment(models.Model):
return "attachments" return "attachments"
def save(self, *args, **kwargs):
# Either 'attachment' or 'link' must be specified!
if not self.attachment and not self.link:
raise ValidationError({
'attachment': _('Missing file'),
'link': _('Missing external link'),
})
super().save(*args, **kwargs)
def __str__(self): def __str__(self):
return os.path.basename(self.attachment.name) if self.attachment is not None:
return os.path.basename(self.attachment.name)
else:
return str(self.link)
attachment = models.FileField(upload_to=rename_attachment, verbose_name=_('Attachment'), attachment = models.FileField(upload_to=rename_attachment, verbose_name=_('Attachment'),
help_text=_('Select file to attach')) help_text=_('Select file to attach'),
blank=True, null=True
)
link = InvenTreeURLField(
blank=True, null=True,
verbose_name=_('Link'),
help_text=_('Link to external URL')
)
comment = models.CharField(blank=True, max_length=100, verbose_name=_('Comment'), help_text=_('File comment')) comment = models.CharField(blank=True, max_length=100, verbose_name=_('Comment'), help_text=_('File comment'))
@ -123,7 +148,10 @@ class InvenTreeAttachment(models.Model):
@property @property
def basename(self): def basename(self):
return os.path.basename(self.attachment.name) if self.attachment:
return os.path.basename(self.attachment.name)
else:
return None
@basename.setter @basename.setter
def basename(self, fn): def basename(self, fn):

View File

@ -239,22 +239,6 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
return data return data
class InvenTreeAttachmentSerializer(InvenTreeModelSerializer):
"""
Special case of an InvenTreeModelSerializer, which handles an "attachment" model.
The only real addition here is that we support "renaming" of the attachment file.
"""
# The 'filename' field must be present in the serializer
filename = serializers.CharField(
label=_('Filename'),
required=False,
source='basename',
allow_blank=False,
)
class InvenTreeAttachmentSerializerField(serializers.FileField): class InvenTreeAttachmentSerializerField(serializers.FileField):
""" """
Override the DRF native FileField serializer, Override the DRF native FileField serializer,
@ -284,6 +268,27 @@ class InvenTreeAttachmentSerializerField(serializers.FileField):
return os.path.join(str(settings.MEDIA_URL), str(value)) return os.path.join(str(settings.MEDIA_URL), str(value))
class InvenTreeAttachmentSerializer(InvenTreeModelSerializer):
"""
Special case of an InvenTreeModelSerializer, which handles an "attachment" model.
The only real addition here is that we support "renaming" of the attachment file.
"""
attachment = InvenTreeAttachmentSerializerField(
required=False,
allow_null=False,
)
# The 'filename' field must be present in the serializer
filename = serializers.CharField(
label=_('Filename'),
required=False,
source='basename',
allow_blank=False,
)
class InvenTreeImageSerializerField(serializers.ImageField): class InvenTreeImageSerializerField(serializers.ImageField):
""" """
Custom image serializer. Custom image serializer.

View File

@ -26,6 +26,7 @@ import moneyed
import yaml import yaml
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.contrib.messages import constants as messages from django.contrib.messages import constants as messages
import django.conf.locale
def _is_true(x): def _is_true(x):
@ -682,6 +683,25 @@ LANGUAGES = [
('zh-cn', _('Chinese')), ('zh-cn', _('Chinese')),
] ]
# Testing interface translations
if get_setting('TEST_TRANSLATIONS', False):
# Set default language
LANGUAGE_CODE = 'xx'
# Add to language catalog
LANGUAGES.append(('xx', 'Test'))
# Add custom languages not provided by Django
EXTRA_LANG_INFO = {
'xx': {
'code': 'xx',
'name': 'Test',
'name_local': 'Test'
},
}
LANG_INFO = dict(django.conf.locale.LANG_INFO, **EXTRA_LANG_INFO)
django.conf.locale.LANG_INFO = LANG_INFO
# Currencies available for use # Currencies available for use
CURRENCIES = CONFIG.get( CURRENCIES = CONFIG.get(
'currencies', 'currencies',

View File

@ -0,0 +1,25 @@
# Generated by Django 3.2.5 on 2021-11-28 01:51
import InvenTree.fields
import InvenTree.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('build', '0032_auto_20211014_0632'),
]
operations = [
migrations.AddField(
model_name='buildorderattachment',
name='link',
field=InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link'),
),
migrations.AlterField(
model_name='buildorderattachment',
name='attachment',
field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
),
]

View File

@ -16,7 +16,7 @@ from rest_framework import serializers
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer
from InvenTree.serializers import InvenTreeAttachmentSerializerField, UserSerializerBrief from InvenTree.serializers import UserSerializerBrief
import InvenTree.helpers import InvenTree.helpers
from InvenTree.serializers import InvenTreeDecimalField from InvenTree.serializers import InvenTreeDecimalField
@ -516,8 +516,6 @@ class BuildAttachmentSerializer(InvenTreeAttachmentSerializer):
Serializer for a BuildAttachment Serializer for a BuildAttachment
""" """
attachment = InvenTreeAttachmentSerializerField(required=True)
class Meta: class Meta:
model = BuildOrderAttachment model = BuildOrderAttachment
@ -525,6 +523,7 @@ class BuildAttachmentSerializer(InvenTreeAttachmentSerializer):
'pk', 'pk',
'build', 'build',
'attachment', 'attachment',
'link',
'filename', 'filename',
'comment', 'comment',
'upload_date', 'upload_date',

View File

@ -431,53 +431,17 @@ enableDragAndDrop(
} }
); );
// Callback for creating a new attachment loadAttachmentTable('{% url "api-build-attachment-list" %}', {
$('#new-attachment').click(function() { filters: {
build: {{ build.pk }},
constructForm('{% url "api-build-attachment-list" %}', { },
fields: { fields: {
attachment: {}, build: {
comment: {}, value: {{ build.pk }},
build: { hidden: true,
value: {{ build.pk }},
hidden: true,
}
},
method: 'POST',
onSuccess: reloadAttachmentTable,
title: '{% trans "Add Attachment" %}',
});
});
loadAttachmentTable(
'{% url "api-build-attachment-list" %}',
{
filters: {
build: {{ build.pk }},
},
onEdit: function(pk) {
var url = `/api/build/attachment/${pk}/`;
constructForm(url, {
fields: {
filename: {},
comment: {},
},
onSuccess: reloadAttachmentTable,
title: '{% trans "Edit Attachment" %}',
});
},
onDelete: function(pk) {
constructForm(`/api/build/attachment/${pk}/`, {
method: 'DELETE',
confirmMessage: '{% trans "Confirm Delete Operation" %}',
title: '{% trans "Delete Attachment" %}',
onSuccess: reloadAttachmentTable,
});
} }
} }
); });
$('#edit-notes').click(function() { $('#edit-notes').click(function() {
constructForm('{% url "api-build-detail" build.pk %}', { constructForm('{% url "api-build-detail" build.pk %}', {

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,35 @@
# Generated by Django 3.2.5 on 2021-11-28 01:51
import InvenTree.fields
import InvenTree.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('order', '0052_auto_20211014_0631'),
]
operations = [
migrations.AddField(
model_name='purchaseorderattachment',
name='link',
field=InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link'),
),
migrations.AddField(
model_name='salesorderattachment',
name='link',
field=InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link'),
),
migrations.AlterField(
model_name='purchaseorderattachment',
name='attachment',
field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
),
migrations.AlterField(
model_name='salesorderattachment',
name='attachment',
field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
),
]

View File

@ -17,16 +17,15 @@ from rest_framework.serializers import ValidationError
from sql_util.utils import SubqueryCount from sql_util.utils import SubqueryCount
from common.settings import currency_code_mappings
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer
from InvenTree.serializers import InvenTreeAttachmentSerializer from InvenTree.serializers import InvenTreeAttachmentSerializer
from InvenTree.serializers import InvenTreeModelSerializer from InvenTree.serializers import InvenTreeModelSerializer
from InvenTree.serializers import InvenTreeDecimalField from InvenTree.serializers import InvenTreeDecimalField
from InvenTree.serializers import InvenTreeMoneySerializer from InvenTree.serializers import InvenTreeMoneySerializer
from InvenTree.serializers import InvenTreeAttachmentSerializerField
from InvenTree.status_codes import StockStatus from InvenTree.status_codes import StockStatus
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer
from part.serializers import PartBriefSerializer from part.serializers import PartBriefSerializer
import stock.models import stock.models
@ -37,7 +36,7 @@ from .models import PurchaseOrderAttachment, SalesOrderAttachment
from .models import SalesOrder, SalesOrderLineItem from .models import SalesOrder, SalesOrderLineItem
from .models import SalesOrderAllocation from .models import SalesOrderAllocation
from common.settings import currency_code_mappings from users.serializers import OwnerSerializer
class POSerializer(InvenTreeModelSerializer): class POSerializer(InvenTreeModelSerializer):
@ -86,6 +85,8 @@ class POSerializer(InvenTreeModelSerializer):
reference = serializers.CharField(required=True) reference = serializers.CharField(required=True)
responsible_detail = OwnerSerializer(source='responsible', read_only=True, many=False)
class Meta: class Meta:
model = PurchaseOrder model = PurchaseOrder
@ -100,6 +101,7 @@ class POSerializer(InvenTreeModelSerializer):
'overdue', 'overdue',
'reference', 'reference',
'responsible', 'responsible',
'responsible_detail',
'supplier', 'supplier',
'supplier_detail', 'supplier_detail',
'supplier_reference', 'supplier_reference',
@ -374,8 +376,6 @@ class POAttachmentSerializer(InvenTreeAttachmentSerializer):
Serializers for the PurchaseOrderAttachment model Serializers for the PurchaseOrderAttachment model
""" """
attachment = InvenTreeAttachmentSerializerField(required=True)
class Meta: class Meta:
model = PurchaseOrderAttachment model = PurchaseOrderAttachment
@ -383,6 +383,7 @@ class POAttachmentSerializer(InvenTreeAttachmentSerializer):
'pk', 'pk',
'order', 'order',
'attachment', 'attachment',
'link',
'filename', 'filename',
'comment', 'comment',
'upload_date', 'upload_date',
@ -594,8 +595,6 @@ class SOAttachmentSerializer(InvenTreeAttachmentSerializer):
Serializers for the SalesOrderAttachment model Serializers for the SalesOrderAttachment model
""" """
attachment = InvenTreeAttachmentSerializerField(required=True)
class Meta: class Meta:
model = SalesOrderAttachment model = SalesOrderAttachment
@ -604,6 +603,7 @@ class SOAttachmentSerializer(InvenTreeAttachmentSerializer):
'order', 'order',
'attachment', 'attachment',
'filename', 'filename',
'link',
'comment', 'comment',
'upload_date', 'upload_date',
] ]

View File

@ -124,51 +124,16 @@
} }
); );
loadAttachmentTable( loadAttachmentTable('{% url "api-po-attachment-list" %}', {
'{% url "api-po-attachment-list" %}', filters: {
{ order: {{ order.pk }},
filters: { },
order: {{ order.pk }}, fields: {
}, order: {
onEdit: function(pk) { value: {{ order.pk }},
var url = `/api/order/po/attachment/${pk}/`; hidden: true,
constructForm(url, {
fields: {
filename: {},
comment: {},
},
onSuccess: reloadAttachmentTable,
title: '{% trans "Edit Attachment" %}',
});
},
onDelete: function(pk) {
constructForm(`/api/order/po/attachment/${pk}/`, {
method: 'DELETE',
confirmMessage: '{% trans "Confirm Delete Operation" %}',
title: '{% trans "Delete Attachment" %}',
onSuccess: reloadAttachmentTable,
});
} }
} }
);
$("#new-attachment").click(function() {
constructForm('{% url "api-po-attachment-list" %}', {
method: 'POST',
fields: {
attachment: {},
comment: {},
order: {
value: {{ order.pk }},
hidden: true,
},
},
reload: true,
title: '{% trans "Add Attachment" %}',
});
}); });
loadStockTable($("#stock-table"), { loadStockTable($("#stock-table"), {

View File

@ -110,55 +110,21 @@
}, },
label: 'attachment', label: 'attachment',
success: function(data, status, xhr) { success: function(data, status, xhr) {
location.reload(); reloadAttachmentTable();
} }
} }
); );
loadAttachmentTable( loadAttachmentTable('{% url "api-so-attachment-list" %}', {
'{% url "api-so-attachment-list" %}', filters: {
{ order: {{ order.pk }},
filters: { },
order: {{ order.pk }}, fields: {
order: {
value: {{ order.pk }},
hidden: true,
}, },
onEdit: function(pk) {
var url = `/api/order/so/attachment/${pk}/`;
constructForm(url, {
fields: {
filename: {},
comment: {},
},
onSuccess: reloadAttachmentTable,
title: '{% trans "Edit Attachment" %}',
});
},
onDelete: function(pk) {
constructForm(`/api/order/so/attachment/${pk}/`, {
method: 'DELETE',
confirmMessage: '{% trans "Confirm Delete Operation" %}',
title: '{% trans "Delete Attachment" %}',
onSuccess: reloadAttachmentTable,
});
}
} }
);
$("#new-attachment").click(function() {
constructForm('{% url "api-so-attachment-list" %}', {
method: 'POST',
fields: {
attachment: {},
comment: {},
order: {
value: {{ order.pk }},
hidden: true
}
},
onSuccess: reloadAttachmentTable,
title: '{% trans "Add Attachment" %}'
});
}); });
loadBuildTable($("#builds-table"), { loadBuildTable($("#builds-table"), {

View File

@ -26,7 +26,7 @@ from djmoney.contrib.exchange.exceptions import MissingRate
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from .models import Part, PartCategory from .models import Part, PartCategory, PartRelated
from .models import BomItem, BomItemSubstitute from .models import BomItem, BomItemSubstitute
from .models import PartParameter, PartParameterTemplate from .models import PartParameter, PartParameterTemplate
from .models import PartAttachment, PartTestTemplate from .models import PartAttachment, PartTestTemplate
@ -42,7 +42,7 @@ from build.models import Build
from . import serializers as part_serializers from . import serializers as part_serializers
from InvenTree.helpers import str2bool, isNull from InvenTree.helpers import str2bool, isNull, increment
from InvenTree.api import AttachmentMixin from InvenTree.api import AttachmentMixin
from InvenTree.status_codes import BuildStatus from InvenTree.status_codes import BuildStatus
@ -410,6 +410,33 @@ class PartThumbsUpdate(generics.RetrieveUpdateAPIView):
] ]
class PartSerialNumberDetail(generics.RetrieveAPIView):
"""
API endpoint for returning extra serial number information about a particular part
"""
queryset = Part.objects.all()
def retrieve(self, request, *args, **kwargs):
part = self.get_object()
# Calculate the "latest" serial number
latest = part.getLatestSerialNumber()
data = {
'latest': latest,
}
if latest is not None:
next = increment(latest)
if next != increment:
data['next'] = next
return Response(data)
class PartDetail(generics.RetrieveUpdateDestroyAPIView): class PartDetail(generics.RetrieveUpdateDestroyAPIView):
""" API endpoint for detail view of a single Part object """ """ API endpoint for detail view of a single Part object """
@ -901,6 +928,40 @@ class PartList(generics.ListCreateAPIView):
queryset = queryset.filter(pk__in=pks) queryset = queryset.filter(pk__in=pks)
# Filter by 'related' parts?
related = params.get('related', None)
exclude_related = params.get('exclude_related', None)
if related is not None or exclude_related is not None:
try:
pk = related if related is not None else exclude_related
pk = int(pk)
related_part = Part.objects.get(pk=pk)
part_ids = set()
# Return any relationship which points to the part in question
relation_filter = Q(part_1=related_part) | Q(part_2=related_part)
for relation in PartRelated.objects.filter(relation_filter):
if relation.part_1.pk != pk:
part_ids.add(relation.part_1.pk)
if relation.part_2.pk != pk:
part_ids.add(relation.part_2.pk)
if related is not None:
# Only return related results
queryset = queryset.filter(pk__in=[pk for pk in part_ids])
elif exclude_related is not None:
# Exclude related results
queryset = queryset.exclude(pk__in=[pk for pk in part_ids])
except (ValueError, Part.DoesNotExist):
pass
# Filter by 'starred' parts? # Filter by 'starred' parts?
starred = params.get('starred', None) starred = params.get('starred', None)
@ -1017,6 +1078,44 @@ class PartList(generics.ListCreateAPIView):
] ]
class PartRelatedList(generics.ListCreateAPIView):
"""
API endpoint for accessing a list of PartRelated objects
"""
queryset = PartRelated.objects.all()
serializer_class = part_serializers.PartRelationSerializer
def filter_queryset(self, queryset):
queryset = super().filter_queryset(queryset)
params = self.request.query_params
# Add a filter for "part" - we can filter either part_1 or part_2
part = params.get('part', None)
if part is not None:
try:
part = Part.objects.get(pk=part)
queryset = queryset.filter(Q(part_1=part) | Q(part_2=part))
except (ValueError, Part.DoesNotExist):
pass
return queryset
class PartRelatedDetail(generics.RetrieveUpdateDestroyAPIView):
"""
API endpoint for accessing detail view of a PartRelated object
"""
queryset = PartRelated.objects.all()
serializer_class = part_serializers.PartRelationSerializer
class PartParameterTemplateList(generics.ListCreateAPIView): class PartParameterTemplateList(generics.ListCreateAPIView):
""" API endpoint for accessing a list of PartParameterTemplate objects. """ API endpoint for accessing a list of PartParameterTemplate objects.
@ -1081,24 +1180,6 @@ class BomFilter(rest_filters.FilterSet):
inherited = rest_filters.BooleanFilter(label='BOM line is inherited') inherited = rest_filters.BooleanFilter(label='BOM line is inherited')
allow_variants = rest_filters.BooleanFilter(label='Variants are allowed') allow_variants = rest_filters.BooleanFilter(label='Variants are allowed')
validated = rest_filters.BooleanFilter(label='BOM line has been validated', method='filter_validated')
def filter_validated(self, queryset, name, value):
# Work out which lines have actually been validated
pks = []
for bom_item in queryset.all():
if bom_item.is_line_valid():
pks.append(bom_item.pk)
if str2bool(value):
queryset = queryset.filter(pk__in=pks)
else:
queryset = queryset.exclude(pk__in=pks)
return queryset
# Filters for linked 'part' # Filters for linked 'part'
part_active = rest_filters.BooleanFilter(label='Master part is active', field_name='part__active') part_active = rest_filters.BooleanFilter(label='Master part is active', field_name='part__active')
part_trackable = rest_filters.BooleanFilter(label='Master part is trackable', field_name='part__trackable') part_trackable = rest_filters.BooleanFilter(label='Master part is trackable', field_name='part__trackable')
@ -1107,6 +1188,30 @@ class BomFilter(rest_filters.FilterSet):
sub_part_trackable = rest_filters.BooleanFilter(label='Sub part is trackable', field_name='sub_part__trackable') sub_part_trackable = rest_filters.BooleanFilter(label='Sub part is trackable', field_name='sub_part__trackable')
sub_part_assembly = rest_filters.BooleanFilter(label='Sub part is an assembly', field_name='sub_part__assembly') sub_part_assembly = rest_filters.BooleanFilter(label='Sub part is an assembly', field_name='sub_part__assembly')
validated = rest_filters.BooleanFilter(label='BOM line has been validated', method='filter_validated')
def filter_validated(self, queryset, name, value):
# Work out which lines have actually been validated
pks = []
value = str2bool(value)
# Shortcut for quicker filtering - BomItem with empty 'checksum' values are not validated
if value:
queryset = queryset.exclude(checksum=None).exclude(checksum='')
for bom_item in queryset.all():
if bom_item.is_line_valid:
pks.append(bom_item.pk)
if value:
queryset = queryset.filter(pk__in=pks)
else:
queryset = queryset.exclude(pk__in=pks)
return queryset
class BomList(generics.ListCreateAPIView): class BomList(generics.ListCreateAPIView):
""" """
@ -1435,6 +1540,12 @@ part_api_urls = [
url(r'^.*$', PartInternalPriceList.as_view(), name='api-part-internal-price-list'), url(r'^.*$', PartInternalPriceList.as_view(), name='api-part-internal-price-list'),
])), ])),
# Base URL for PartRelated API endpoints
url(r'^related/', include([
url(r'^(?P<pk>\d+)/', PartRelatedDetail.as_view(), name='api-part-related-detail'),
url(r'^.*$', PartRelatedList.as_view(), name='api-part-related-list'),
])),
# Base URL for PartParameter API endpoints # Base URL for PartParameter API endpoints
url(r'^parameter/', include([ url(r'^parameter/', include([
url(r'^template/$', PartParameterTemplateList.as_view(), name='api-part-parameter-template-list'), url(r'^template/$', PartParameterTemplateList.as_view(), name='api-part-parameter-template-list'),
@ -1448,7 +1559,14 @@ part_api_urls = [
url(r'^(?P<pk>\d+)/?', PartThumbsUpdate.as_view(), name='api-part-thumbs-update'), url(r'^(?P<pk>\d+)/?', PartThumbsUpdate.as_view(), name='api-part-thumbs-update'),
])), ])),
url(r'^(?P<pk>\d+)/', PartDetail.as_view(), name='api-part-detail'), url(r'^(?P<pk>\d+)/', include([
# Endpoint for extra serial number information
url(r'^serial-numbers/', PartSerialNumberDetail.as_view(), name='api-part-serial-number-detail'),
# Part detail endpoint
url(r'^.*$', PartDetail.as_view(), name='api-part-detail'),
])),
url(r'^.*$', PartList.as_view(), name='api-part-list'), url(r'^.*$', PartList.as_view(), name='api-part-list'),
] ]

View File

@ -17,7 +17,7 @@ from InvenTree.fields import RoundingDecimalFormField
import common.models import common.models
from common.forms import MatchItemForm from common.forms import MatchItemForm
from .models import Part, PartCategory, PartRelated from .models import Part, PartCategory
from .models import PartParameterTemplate from .models import PartParameterTemplate
from .models import PartCategoryParameterTemplate from .models import PartCategoryParameterTemplate
from .models import PartSellPriceBreak, PartInternalPriceBreak from .models import PartSellPriceBreak, PartInternalPriceBreak
@ -157,20 +157,6 @@ class BomMatchItemForm(MatchItemForm):
return super().get_special_field(col_guess, row, file_manager) return super().get_special_field(col_guess, row, file_manager)
class CreatePartRelatedForm(HelperForm):
""" Form for creating a PartRelated object """
class Meta:
model = PartRelated
fields = [
'part_1',
'part_2',
]
labels = {
'part_2': _('Related Part'),
}
class SetPartCategoryForm(forms.Form): class SetPartCategoryForm(forms.Form):
""" Form for setting the category of multiple Part objects """ """ Form for setting the category of multiple Part objects """

View File

@ -0,0 +1,25 @@
# Generated by Django 3.2.5 on 2021-11-28 01:51
import InvenTree.fields
import InvenTree.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0074_partcategorystar'),
]
operations = [
migrations.AddField(
model_name='partattachment',
name='link',
field=InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link'),
),
migrations.AlterField(
model_name='partattachment',
name='attachment',
field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
),
]

View File

@ -25,7 +25,7 @@ from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
from stock.models import StockItem from stock.models import StockItem
from .models import (BomItem, BomItemSubstitute, from .models import (BomItem, BomItemSubstitute,
Part, PartAttachment, PartCategory, Part, PartAttachment, PartCategory, PartRelated,
PartParameter, PartParameterTemplate, PartSellPriceBreak, PartParameter, PartParameterTemplate, PartSellPriceBreak,
PartStar, PartTestTemplate, PartCategoryParameterTemplate, PartStar, PartTestTemplate, PartCategoryParameterTemplate,
PartInternalPriceBreak) PartInternalPriceBreak)
@ -75,8 +75,6 @@ class PartAttachmentSerializer(InvenTreeAttachmentSerializer):
Serializer for the PartAttachment class Serializer for the PartAttachment class
""" """
attachment = InvenTreeAttachmentSerializerField(required=True)
class Meta: class Meta:
model = PartAttachment model = PartAttachment
@ -85,6 +83,7 @@ class PartAttachmentSerializer(InvenTreeAttachmentSerializer):
'part', 'part',
'attachment', 'attachment',
'filename', 'filename',
'link',
'comment', 'comment',
'upload_date', 'upload_date',
] ]
@ -388,6 +387,25 @@ class PartSerializer(InvenTreeModelSerializer):
] ]
class PartRelationSerializer(InvenTreeModelSerializer):
"""
Serializer for a PartRelated model
"""
part_1_detail = PartSerializer(source='part_1', read_only=True, many=False)
part_2_detail = PartSerializer(source='part_2', read_only=True, many=False)
class Meta:
model = PartRelated
fields = [
'pk',
'part_1',
'part_1_detail',
'part_2',
'part_2_detail',
]
class PartStarSerializer(InvenTreeModelSerializer): class PartStarSerializer(InvenTreeModelSerializer):
""" Serializer for a PartStar object """ """ Serializer for a PartStar object """

View File

@ -330,33 +330,7 @@
</div> </div>
</div> </div>
<table id='table-related-part' class='table table-condensed table-striped' data-toolbar='#related-button-toolbar'> <table id='related-parts-table' class='table table-striped table-condensed' data-toolbar='#related-button-toolbar'></table>
<thead>
<tr>
<th data-field='part' data-serachable='true'>{% trans "Part" %}</th>
</tr>
</thead>
<tbody>
{% for item in part.get_related_parts %}
{% with part_related=item.0 part=item.1 %}
<tr>
<td>
<a class='hover-icon'>
<img class='hover-img-thumb' src='{{ part.get_thumbnail_url }}'>
<img class='hover-img-large' src='{{ part.get_thumbnail_url }}'>
</a>
<a href='/part/{{ part.id }}/'>{{ part }}</a>
<div class='btn-group' style='float: right;'>
{% if roles.part.change %}
<button title='{% trans "Delete" %}' class='btn btn-outline-secondary delete-related-part' url="{% url 'part-related-delete' part_related.id %}" type='button'><span class='fas fa-trash-alt icon-red'/></button>
{% endif %}
</div>
</td>
</tr>
{% endwith %}
{% endfor %}
</tbody>
</table>
</div> </div>
</div> </div>
@ -771,15 +745,34 @@
// Load the "related parts" tab // Load the "related parts" tab
onPanelLoad("related-parts", function() { onPanelLoad("related-parts", function() {
$('#table-related-part').inventreeTable({
}); loadRelatedPartsTable(
"#related-parts-table",
{{ part.pk }}
);
$("#add-related-part").click(function() { $("#add-related-part").click(function() {
launchModalForm("{% url 'part-related-create' %}", {
data: { constructForm('{% url "api-part-related-list" %}', {
part: {{ part.id }}, method: 'POST',
fields: {
part_1: {
hidden: true,
value: {{ part.pk }},
},
part_2: {
label: '{% trans "Related Part" %}',
filters: {
exclude_id: {{ part.pk }},
exclude_related: {{ part.pk }},
}
}
}, },
reload: true, focus: 'part_2',
title: '{% trans "Add Related Part" %}',
onSuccess: function() {
$('#related-parts-table').bootstrapTable('refresh');
}
}); });
}); });
@ -1006,36 +999,17 @@
}); });
onPanelLoad("part-attachments", function() { onPanelLoad("part-attachments", function() {
loadAttachmentTable( loadAttachmentTable('{% url "api-part-attachment-list" %}', {
'{% url "api-part-attachment-list" %}', filters: {
{ part: {{ part.pk }},
filters: { },
part: {{ part.pk }}, fields: {
}, part: {
onEdit: function(pk) { value: {{ part.pk }},
var url = `/api/part/attachment/${pk}/`; hidden: true
constructForm(url, {
fields: {
filename: {},
comment: {},
},
title: '{% trans "Edit Attachment" %}',
onSuccess: reloadAttachmentTable,
});
},
onDelete: function(pk) {
var url = `/api/part/attachment/${pk}/`;
constructForm(url, {
method: 'DELETE',
confirmMessage: '{% trans "Confirm Delete Operation" %}',
title: '{% trans "Delete Attachment" %}',
onSuccess: reloadAttachmentTable,
});
} }
} }
); });
enableDragAndDrop( enableDragAndDrop(
'#attachment-dropzone', '#attachment-dropzone',
@ -1050,26 +1024,6 @@
} }
} }
); );
$("#new-attachment").click(function() {
constructForm(
'{% url "api-part-attachment-list" %}',
{
method: 'POST',
fields: {
attachment: {},
comment: {},
part: {
value: {{ part.pk }},
hidden: true,
}
},
onSuccess: reloadAttachmentTable,
title: '{% trans "Add Attachment" %}',
}
)
});
}); });

View File

@ -925,7 +925,46 @@ class BomItemTest(InvenTreeAPITestCase):
expected_code=200 expected_code=200
) )
print("results:", len(response.data)) # Filter by "validated"
response = self.get(
url,
data={
'validated': True,
},
expected_code=200,
)
# Should be zero validated results
self.assertEqual(len(response.data), 0)
# Now filter by "not validated"
response = self.get(
url,
data={
'validated': False,
},
expected_code=200
)
# There should be at least one non-validated item
self.assertTrue(len(response.data) > 0)
# Now, let's validate an item
bom_item = BomItem.objects.first()
bom_item.validate_hash()
response = self.get(
url,
data={
'validated': True,
},
expected_code=200
)
# Check that the expected response is returned
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]['pk'], bom_item.pk)
def test_get_bom_detail(self): def test_get_bom_detail(self):
""" """

View File

@ -5,7 +5,7 @@ from django.urls import reverse
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from .models import Part, PartRelated from .models import Part
class PartViewTestCase(TestCase): class PartViewTestCase(TestCase):
@ -145,36 +145,6 @@ class PartDetailTest(PartViewTestCase):
self.assertIn('streaming_content', dir(response)) self.assertIn('streaming_content', dir(response))
class PartRelatedTests(PartViewTestCase):
def test_valid_create(self):
""" test creation of a related part """
# Test GET view
response = self.client.get(reverse('part-related-create'), {'part': 1},
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# Test POST view with valid form data
response = self.client.post(reverse('part-related-create'), {'part_1': 1, 'part_2': 2},
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertContains(response, '"form_valid": true', status_code=200)
# Try to create the same relationship with part_1 and part_2 pks reversed
response = self.client.post(reverse('part-related-create'), {'part_1': 2, 'part_2': 1},
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertContains(response, '"form_valid": false', status_code=200)
# Try to create part related to itself
response = self.client.post(reverse('part-related-create'), {'part_1': 1, 'part_2': 1},
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertContains(response, '"form_valid": false', status_code=200)
# Check final count
n = PartRelated.objects.all().count()
self.assertEqual(n, 1)
class PartQRTest(PartViewTestCase): class PartQRTest(PartViewTestCase):
""" Tests for the Part QR Code AJAX view """ """ Tests for the Part QR Code AJAX view """

View File

@ -12,10 +12,6 @@ from django.conf.urls import url, include
from . import views from . import views
part_related_urls = [
url(r'^new/?', views.PartRelatedCreate.as_view(), name='part-related-create'),
url(r'^(?P<pk>\d+)/delete/?', views.PartRelatedDelete.as_view(), name='part-related-delete'),
]
sale_price_break_urls = [ sale_price_break_urls = [
url(r'^new/', views.PartSalePriceBreakCreate.as_view(), name='sale-price-break-create'), url(r'^new/', views.PartSalePriceBreakCreate.as_view(), name='sale-price-break-create'),
@ -96,9 +92,6 @@ part_urls = [
# Part category # Part category
url(r'^category/', include(category_urls)), url(r'^category/', include(category_urls)),
# Part related
url(r'^related-parts/', include(part_related_urls)),
# Part price breaks # Part price breaks
url(r'^sale-price/', include(sale_price_break_urls)), url(r'^sale-price/', include(sale_price_break_urls)),

View File

@ -30,7 +30,7 @@ import io
from rapidfuzz import fuzz from rapidfuzz import fuzz
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from .models import PartCategory, Part, PartRelated from .models import PartCategory, Part
from .models import PartParameterTemplate from .models import PartParameterTemplate
from .models import PartCategoryParameterTemplate from .models import PartCategoryParameterTemplate
from .models import BomItem from .models import BomItem
@ -85,75 +85,6 @@ class PartIndex(InvenTreeRoleMixin, ListView):
return context return context
class PartRelatedCreate(AjaxCreateView):
""" View for creating a new PartRelated object
- The view only makes sense if a Part object is passed to it
"""
model = PartRelated
form_class = part_forms.CreatePartRelatedForm
ajax_form_title = _("Add Related Part")
ajax_template_name = "modal_form.html"
def get_initial(self):
""" Set parent part as part_1 field """
initials = {}
part_id = self.request.GET.get('part', None)
if part_id:
try:
initials['part_1'] = Part.objects.get(pk=part_id)
except (Part.DoesNotExist, ValueError):
pass
return initials
def get_form(self):
""" Create a form to upload a new PartRelated
- Hide the 'part_1' field (parent part)
- Display parts which are not yet related
"""
form = super(AjaxCreateView, self).get_form()
form.fields['part_1'].widget = HiddenInput()
try:
# Get parent part
parent_part = self.get_initial()['part_1']
# Get existing related parts
related_parts = [related_part[1].pk for related_part in parent_part.get_related_parts()]
# Build updated choice list excluding
# - parts already related to parent part
# - the parent part itself
updated_choices = []
for choice in form.fields["part_2"].choices:
if (choice[0] not in related_parts) and (choice[0] != parent_part.pk):
updated_choices.append(choice)
# Update choices for related part
form.fields['part_2'].choices = updated_choices
except KeyError:
pass
return form
class PartRelatedDelete(AjaxDeleteView):
""" View for deleting a PartRelated object """
model = PartRelated
ajax_form_title = _("Delete Related Part")
context_object_name = "related"
# Explicit role requirement
role_required = 'part.change'
class PartSetCategory(AjaxUpdateView): class PartSetCategory(AjaxUpdateView):
""" View for settings the part category for multiple parts at once """ """ View for settings the part category for multiple parts at once """

View File

@ -0,0 +1,25 @@
# Generated by Django 3.2.5 on 2021-11-28 01:51
import InvenTree.fields
import InvenTree.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stock', '0069_auto_20211109_2347'),
]
operations = [
migrations.AddField(
model_name='stockitemattachment',
name='link',
field=InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link'),
),
migrations.AlterField(
model_name='stockitemattachment',
name='attachment',
field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
),
]

View File

@ -420,8 +420,6 @@ class StockItemAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSer
user_detail = InvenTree.serializers.UserSerializerBrief(source='user', read_only=True) user_detail = InvenTree.serializers.UserSerializerBrief(source='user', read_only=True)
attachment = InvenTree.serializers.InvenTreeAttachmentSerializerField(required=True)
# TODO: Record the uploading user when creating or updating an attachment! # TODO: Record the uploading user when creating or updating an attachment!
class Meta: class Meta:
@ -432,6 +430,7 @@ class StockItemAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSer
'stock_item', 'stock_item',
'attachment', 'attachment',
'filename', 'filename',
'link',
'comment', 'comment',
'upload_date', 'upload_date',
'user', 'user',

View File

@ -221,55 +221,16 @@
} }
); );
loadAttachmentTable( loadAttachmentTable('{% url "api-stock-attachment-list" %}', {
'{% url "api-stock-attachment-list" %}', filters: {
{ stock_item: {{ item.pk }},
filters: { },
stock_item: {{ item.pk }}, fields: {
}, stock_item: {
onEdit: function(pk) { value: {{ item.pk }},
var url = `/api/stock/attachment/${pk}/`; hidden: true,
constructForm(url, {
fields: {
filename: {},
comment: {},
},
title: '{% trans "Edit Attachment" %}',
onSuccess: reloadAttachmentTable
});
},
onDelete: function(pk) {
var url = `/api/stock/attachment/${pk}/`;
constructForm(url, {
method: 'DELETE',
confirmMessage: '{% trans "Confirm Delete Operation" %}',
title: '{% trans "Delete Attachment" %}',
onSuccess: reloadAttachmentTable,
});
} }
} }
);
$("#new-attachment").click(function() {
constructForm(
'{% url "api-stock-attachment-list" %}',
{
method: 'POST',
fields: {
attachment: {},
comment: {},
stock_item: {
value: {{ item.pk }},
hidden: true,
},
},
reload: true,
title: '{% trans "Add Attachment" %}',
}
);
}); });
loadStockTestResultsTable( loadStockTestResultsTable(

View File

@ -433,6 +433,7 @@
$("#stock-serialize").click(function() { $("#stock-serialize").click(function() {
serializeStockItem({{ item.pk }}, { serializeStockItem({{ item.pk }}, {
part: {{ item.part.pk }},
reload: true, reload: true,
data: { data: {
quantity: {{ item.quantity }}, quantity: {{ item.quantity }},

View File

@ -250,18 +250,18 @@ $("#param-table").inventreeTable({
columns: [ columns: [
{ {
field: 'pk', field: 'pk',
title: 'ID', title: '{% trans "ID" %}',
visible: false, visible: false,
switchable: false, switchable: false,
}, },
{ {
field: 'name', field: 'name',
title: 'Name', title: '{% trans "Name" %}',
sortable: 'true', sortable: 'true',
}, },
{ {
field: 'units', field: 'units',
title: 'Units', title: '{% trans "Units" %}',
sortable: 'true', sortable: 'true',
}, },
{ {

View File

@ -210,14 +210,14 @@
<div class='input-group-append'> <div class='input-group-append'>
<input type="submit" value="{% trans 'Set Language' %}" class="btn btn btn-primary"> <input type="submit" value="{% trans 'Set Language' %}" class="btn btn btn-primary">
</div> </div>
<p>{% trans "Some languages are not complete" %}
{% if ALL_LANG %}
. <a href="{% url 'settings' %}">{% trans "Show only sufficent" %}</a>
{% else %}
and hidden. <a href="?alllang">{% trans "Show them too" %}</a>
{% endif %}
</p>
</div> </div>
<p>{% trans "Some languages are not complete" %}
{% if ALL_LANG %}
. <a href="{% url 'settings' %}">{% trans "Show only sufficent" %}</a>
{% else %}
{% trans "and hidden." %} <a href="?alllang">{% trans "Show them too" %}</a>
{% endif %}
</p>
</form> </form>
</div> </div>
<div class="col-sm-6"> <div class="col-sm-6">

View File

@ -1,5 +1,8 @@
{% load i18n %} {% load i18n %}
<button type='button' class='btn btn-outline-success' id='new-attachment-link'>
<span class='fas fa-link'></span> {% trans "Add Link" %}
</button>
<button type='button' class='btn btn-success' id='new-attachment'> <button type='button' class='btn btn-success' id='new-attachment'>
<span class='fas fa-plus-circle'></span> {% trans "Add Attachment" %} <span class='fas fa-plus-circle'></span> {% trans "Add Attachment" %}
</button> </button>

View File

@ -54,6 +54,7 @@ function inventreeGet(url, filters={}, options={}) {
data: filters, data: filters,
dataType: 'json', dataType: 'json',
contentType: 'application/json', contentType: 'application/json',
async: (options.async == false) ? false : true,
success: function(response) { success: function(response) {
if (options.success) { if (options.success) {
options.success(response); options.success(response);

View File

@ -6,10 +6,57 @@
*/ */
/* exported /* exported
addAttachmentButtonCallbacks,
loadAttachmentTable, loadAttachmentTable,
reloadAttachmentTable, reloadAttachmentTable,
*/ */
/*
* Add callbacks to buttons for creating new attachments.
*
* Note: Attachments can also be external links!
*/
function addAttachmentButtonCallbacks(url, fields={}) {
// Callback for 'new attachment' button
$('#new-attachment').click(function() {
var file_fields = {
attachment: {},
comment: {},
};
Object.assign(file_fields, fields);
constructForm(url, {
fields: file_fields,
method: 'POST',
onSuccess: reloadAttachmentTable,
title: '{% trans "Add Attachment" %}',
});
});
// Callback for 'new link' button
$('#new-attachment-link').click(function() {
var link_fields = {
link: {},
comment: {},
};
Object.assign(link_fields, fields);
constructForm(url, {
fields: link_fields,
method: 'POST',
onSuccess: reloadAttachmentTable,
title: '{% trans "Add Link" %}',
});
});
}
function reloadAttachmentTable() { function reloadAttachmentTable() {
$('#attachment-table').bootstrapTable('refresh'); $('#attachment-table').bootstrapTable('refresh');
@ -20,6 +67,8 @@ function loadAttachmentTable(url, options) {
var table = options.table || '#attachment-table'; var table = options.table || '#attachment-table';
addAttachmentButtonCallbacks(url, options.fields || {});
$(table).inventreeTable({ $(table).inventreeTable({
url: url, url: url,
name: options.name || 'attachments', name: options.name || 'attachments',
@ -34,56 +83,77 @@ function loadAttachmentTable(url, options) {
$(table).find('.button-attachment-edit').click(function() { $(table).find('.button-attachment-edit').click(function() {
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
if (options.onEdit) { constructForm(`${url}${pk}/`, {
options.onEdit(pk); fields: {
} link: {},
comment: {},
},
processResults: function(data, fields, opts) {
// Remove the "link" field if the attachment is a file!
if (data.attachment) {
delete opts.fields.link;
}
},
onSuccess: reloadAttachmentTable,
title: '{% trans "Edit Attachment" %}',
});
}); });
// Add callback for 'delete' button // Add callback for 'delete' button
$(table).find('.button-attachment-delete').click(function() { $(table).find('.button-attachment-delete').click(function() {
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
if (options.onDelete) { constructForm(`${url}${pk}/`, {
options.onDelete(pk); method: 'DELETE',
} confirmMessage: '{% trans "Confirm Delete" %}',
title: '{% trans "Delete Attachment" %}',
onSuccess: reloadAttachmentTable,
});
}); });
}, },
columns: [ columns: [
{ {
field: 'attachment', field: 'attachment',
title: '{% trans "File" %}', title: '{% trans "Attachment" %}',
formatter: function(value) { formatter: function(value, row) {
var icon = 'fa-file-alt'; if (row.attachment) {
var icon = 'fa-file-alt';
var fn = value.toLowerCase(); var fn = value.toLowerCase();
if (fn.endsWith('.csv')) { if (fn.endsWith('.csv')) {
icon = 'fa-file-csv'; icon = 'fa-file-csv';
} else if (fn.endsWith('.pdf')) { } else if (fn.endsWith('.pdf')) {
icon = 'fa-file-pdf'; icon = 'fa-file-pdf';
} else if (fn.endsWith('.xls') || fn.endsWith('.xlsx')) { } else if (fn.endsWith('.xls') || fn.endsWith('.xlsx')) {
icon = 'fa-file-excel'; icon = 'fa-file-excel';
} else if (fn.endsWith('.doc') || fn.endsWith('.docx')) { } else if (fn.endsWith('.doc') || fn.endsWith('.docx')) {
icon = 'fa-file-word'; icon = 'fa-file-word';
} else if (fn.endsWith('.zip') || fn.endsWith('.7z')) { } else if (fn.endsWith('.zip') || fn.endsWith('.7z')) {
icon = 'fa-file-archive'; icon = 'fa-file-archive';
} else {
var images = ['.png', '.jpg', '.bmp', '.gif', '.svg', '.tif'];
images.forEach(function(suffix) {
if (fn.endsWith(suffix)) {
icon = 'fa-file-image';
}
});
}
var split = value.split('/');
var filename = split[split.length - 1];
var html = `<span class='fas ${icon}'></span> ${filename}`;
return renderLink(html, value);
} else if (row.link) {
var html = `<span class='fas fa-link'></span> ${row.link}`;
return renderLink(html, row.link);
} else { } else {
var images = ['.png', '.jpg', '.bmp', '.gif', '.svg', '.tif']; return '-';
images.forEach(function(suffix) {
if (fn.endsWith(suffix)) {
icon = 'fa-file-image';
}
});
} }
var split = value.split('/');
var filename = split[split.length - 1];
var html = `<span class='fas ${icon}'></span> ${filename}`;
return renderLink(html, value);
} }
}, },
{ {

View File

@ -192,6 +192,7 @@ function bomSubstitutesDialog(bom_item_id, substitutes, options={}) {
</a> </a>
</td> </td>
<td id='description-${pk}'><em>${part.description}</em></td> <td id='description-${pk}'><em>${part.description}</em></td>
<td id='stock-${pk}'><em>${part.stock}</em></td>
<td>${buttons}</td> <td>${buttons}</td>
</tr> </tr>
`; `;
@ -212,6 +213,7 @@ function bomSubstitutesDialog(bom_item_id, substitutes, options={}) {
<tr> <tr>
<th>{% trans "Part" %}</th> <th>{% trans "Part" %}</th>
<th>{% trans "Description" %}</th> <th>{% trans "Description" %}</th>
<th>{% trans "Stock" %}</th>
<th><!-- Actions --></th> <th><!-- Actions --></th>
</tr> </tr>
</thead> </thead>

View File

@ -124,6 +124,7 @@ function supplierPartFields() {
part_detail: true, part_detail: true,
manufacturer_detail: true, manufacturer_detail: true,
}, },
auto_fill: true,
}, },
description: {}, description: {},
link: { link: {

View File

@ -273,7 +273,7 @@ function setupFilterList(tableKey, table, target) {
var element = $(target); var element = $(target);
if (!element) { if (!element || !element.exists()) {
console.log(`WARNING: setupFilterList could not find target '${target}'`); console.log(`WARNING: setupFilterList could not find target '${target}'`);
return; return;
} }

View File

@ -28,6 +28,7 @@
disableFormInput, disableFormInput,
enableFormInput, enableFormInput,
hideFormInput, hideFormInput,
setFormInputPlaceholder,
setFormGroupVisibility, setFormGroupVisibility,
showFormInput, showFormInput,
*/ */
@ -1276,6 +1277,11 @@ function initializeGroups(fields, options) {
} }
} }
// Set the placeholder value for a field
function setFormInputPlaceholder(name, placeholder, options) {
$(options.modal).find(`#id_${name}`).attr('placeholder', placeholder);
}
// Clear a form input // Clear a form input
function clearFormInput(name, options) { function clearFormInput(name, options) {
updateFieldValue(name, null, {}, options); updateFieldValue(name, null, {}, options);

View File

@ -695,6 +695,23 @@ function loadPurchaseOrderTable(table, options) {
title: '{% trans "Items" %}', title: '{% trans "Items" %}',
sortable: true, sortable: true,
}, },
{
field: 'responsible',
title: '{% trans "Responsible" %}',
switchable: true,
sortable: false,
formatter: function(value, row) {
var html = row.responsible_detail.name;
if (row.responsible_detail.label == 'group') {
html += `<span class='float-right fas fa-users'></span>`;
} else {
html += `<span class='float-right fas fa-user'></span>`;
}
return html;
}
},
], ],
}); });
} }

View File

@ -32,6 +32,7 @@
loadPartTable, loadPartTable,
loadPartTestTemplateTable, loadPartTestTemplateTable,
loadPartVariantTable, loadPartVariantTable,
loadRelatedPartsTable,
loadSellPricingChart, loadSellPricingChart,
loadSimplePartTable, loadSimplePartTable,
loadStockPricingChart, loadStockPricingChart,
@ -705,6 +706,97 @@ function loadPartParameterTable(table, url, options) {
} }
function loadRelatedPartsTable(table, part_id, options={}) {
/*
* Load table of "related" parts
*/
options.params = options.params || {};
options.params.part = part_id;
var filters = {};
for (var key in options.params) {
filters[key] = options.params[key];
}
setupFilterList('related', $(table), options.filterTarget);
function getPart(row) {
if (row.part_1 == part_id) {
return row.part_2_detail;
} else {
return row.part_1_detail;
}
}
var columns = [
{
field: 'name',
title: '{% trans "Part" %}',
switchable: false,
formatter: function(value, row) {
var part = getPart(row);
var html = imageHoverIcon(part.thumbnail) + renderLink(part.full_name, `/part/${part.pk}/`);
html += makePartIcons(part);
return html;
}
},
{
field: 'description',
title: '{% trans "Description" %}',
formatter: function(value, row) {
return getPart(row).description;
}
},
{
field: 'actions',
title: '',
switchable: false,
formatter: function(value, row) {
var html = `<div class='btn-group float-right' role='group'>`;
html += makeIconButton('fa-trash-alt icon-red', 'button-related-delete', row.pk, '{% trans "Delete part relationship" %}');
html += '</div>';
return html;
}
}
];
$(table).inventreeTable({
url: '{% url "api-part-related-list" %}',
groupBy: false,
name: 'related',
original: options.params,
queryParams: filters,
columns: columns,
showColumns: false,
search: true,
onPostBody: function() {
$(table).find('.button-related-delete').click(function() {
var pk = $(this).attr('pk');
constructForm(`/api/part/related/${pk}/`, {
method: 'DELETE',
title: '{% trans "Delete Part Relationship" %}',
onSuccess: function() {
$(table).bootstrapTable('refresh');
}
});
});
},
});
}
function loadParametricPartTable(table, options={}) { function loadParametricPartTable(table, options={}) {
/* Load parametric table for part parameters /* Load parametric table for part parameters
* *
@ -836,6 +928,7 @@ function loadPartTable(table, url, options={}) {
* query: extra query params for API request * query: extra query params for API request
* buttons: If provided, link buttons to selection status of this table * buttons: If provided, link buttons to selection status of this table
* disableFilters: If true, disable custom filters * disableFilters: If true, disable custom filters
* actions: Provide a callback function to construct an "actions" column
*/ */
// Ensure category detail is included // Ensure category detail is included
@ -878,7 +971,7 @@ function loadPartTable(table, url, options={}) {
col = { col = {
field: 'IPN', field: 'IPN',
title: 'IPN', title: '{% trans "IPN" %}',
}; };
if (!options.params.ordering) { if (!options.params.ordering) {
@ -895,7 +988,7 @@ function loadPartTable(table, url, options={}) {
var name = row.full_name; var name = row.full_name;
var display = imageHoverIcon(row.thumbnail) + renderLink(name, '/part/' + row.pk + '/'); var display = imageHoverIcon(row.thumbnail) + renderLink(name, `/part/${row.pk}/`);
display += makePartIcons(row); display += makePartIcons(row);
@ -993,6 +1086,21 @@ function loadPartTable(table, url, options={}) {
} }
}); });
// Push an "actions" column
if (options.actions) {
columns.push({
field: 'actions',
title: '',
switchable: false,
visible: true,
searchable: false,
sortable: false,
formatter: function(value, row) {
return options.actions(value, row);
}
});
}
var grid_view = options.gridView && inventreeLoad('part-grid-view') == 1; var grid_view = options.gridView && inventreeLoad('part-grid-view') == 1;
$(table).inventreeTable({ $(table).inventreeTable({
@ -1020,6 +1128,10 @@ function loadPartTable(table, url, options={}) {
$('#view-part-grid').removeClass('btn-secondary').addClass('btn-outline-secondary'); $('#view-part-grid').removeClass('btn-secondary').addClass('btn-outline-secondary');
$('#view-part-list').removeClass('btn-outline-secondary').addClass('btn-secondary'); $('#view-part-list').removeClass('btn-outline-secondary').addClass('btn-secondary');
} }
if (options.onPostBody) {
options.onPostBody();
}
}, },
buttons: options.gridView ? [ buttons: options.gridView ? [
{ {

View File

@ -80,6 +80,20 @@ function serializeStockItem(pk, options={}) {
notes: {}, notes: {},
}; };
if (options.part) {
// Work out the next available serial number
inventreeGet(`/api/part/${options.part}/serial-numbers/`, {}, {
success: function(data) {
if (data.next) {
options.fields.serial_numbers.placeholder = `{% trans "Next available serial number" %}: ${data.next}`;
} else if (data.latest) {
options.fields.serial_numbers.placeholder = `{% trans "Latest serial number" %}: ${data.latest}`;
}
},
async: false,
});
}
constructForm(url, options); constructForm(url, options);
} }
@ -144,10 +158,26 @@ function stockItemFields(options={}) {
// If a "trackable" part is selected, enable serial number field // If a "trackable" part is selected, enable serial number field
if (data.trackable) { if (data.trackable) {
enableFormInput('serial_numbers', opts); enableFormInput('serial_numbers', opts);
// showFormInput('serial_numbers', opts);
// Request part serial number information from the server
inventreeGet(`/api/part/${data.pk}/serial-numbers/`, {}, {
success: function(data) {
var placeholder = '';
if (data.next) {
placeholder = `{% trans "Next available serial number" %}: ${data.next}`;
} else if (data.latest) {
placeholder = `{% trans "Latest serial number" %}: ${data.latest}`;
}
setFormInputPlaceholder('serial_numbers', placeholder, opts);
}
});
} else { } else {
clearFormInput('serial_numbers', opts); clearFormInput('serial_numbers', opts);
disableFormInput('serial_numbers', opts); disableFormInput('serial_numbers', opts);
setFormInputPlaceholder('serial_numbers', '{% trans "This part cannot be serialized" %}', opts);
} }
// Enable / disable fields based on purchaseable status // Enable / disable fields based on purchaseable status
@ -1101,7 +1131,7 @@ function loadStockTable(table, options) {
col = { col = {
field: 'part_detail.IPN', field: 'part_detail.IPN',
title: 'IPN', title: '{% trans "IPN" %}',
sortName: 'part__IPN', sortName: 'part__IPN',
visible: params['part_detail'], visible: params['part_detail'],
switchable: params['part_detail'], switchable: params['part_detail'],

View File

@ -74,6 +74,12 @@ function getAvailableTableFilters(tableKey) {
}; };
} }
// Filters for the "related parts" table
if (tableKey == 'related') {
return {
};
}
// Filters for the "used in" table // Filters for the "used in" table
if (tableKey == 'usedin') { if (tableKey == 'usedin') {
return { return {

View File

@ -38,5 +38,8 @@ fi
cd ${INVENTREE_HOME} cd ${INVENTREE_HOME}
# Collect translation file stats
invoke translate-stats
# Launch the CMD *after* the ENTRYPOINT completes # Launch the CMD *after* the ENTRYPOINT completes
exec "$@" exec "$@"

View File

@ -3,6 +3,8 @@
import os import os
import json import json
import sys import sys
import pathlib
import re
try: try:
from invoke import ctask as task from invoke import ctask as task
@ -469,6 +471,75 @@ def server(c, address="127.0.0.1:8000"):
manage(c, "runserver {address}".format(address=address), pty=True) manage(c, "runserver {address}".format(address=address), pty=True)
@task(post=[translate_stats, static, server])
def test_translations(c):
"""
Add a fictional language to test if each component is ready for translations
"""
import django
from django.conf import settings
# setup django
base_path = os.getcwd()
new_base_path = pathlib.Path('InvenTree').absolute()
sys.path.append(str(new_base_path))
os.chdir(new_base_path)
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'InvenTree.settings')
django.setup()
# Add language
print("Add dummy language...")
print("========================================")
manage(c, "makemessages -e py,html,js --no-wrap -l xx")
# change translation
print("Fill in dummy translations...")
print("========================================")
file_path = pathlib.Path(settings.LOCALE_PATHS[0], 'xx', 'LC_MESSAGES', 'django.po')
new_file_path = str(file_path) + '_new'
# complie regex
reg = re.compile(
r"[a-zA-Z0-9]{1}"+ # match any single letter and number
r"(?![^{\(\<]*[}\)\>])"+ # that is not inside curly brackets, brackets or a tag
r"(?<![^\%][^\(][)][a-z])"+ # that is not a specially formatted variable with singles
r"(?![^\\][\n])" # that is not a newline
)
last_string = ''
# loop through input file lines
with open(file_path, "rt") as file_org:
with open(new_file_path, "wt") as file_new:
for line in file_org:
if line.startswith('msgstr "'):
# write output -> replace regex matches with x in the read in (multi)string
file_new.write(f'msgstr "{reg.sub("x", last_string[7:-2])}"\n')
last_string = "" # reset (multi)string
elif line.startswith('msgid "'):
last_string = last_string + line # a new translatable string starts -> start append
file_new.write(line)
else:
if last_string:
last_string = last_string + line # a string is beeing read in -> continue appending
file_new.write(line)
# change out translation files
os.rename(file_path, str(file_path) + '_old')
os.rename(new_file_path, file_path)
# compile languages
print("Compile languages ...")
print("========================================")
manage(c, "compilemessages")
# reset cwd
os.chdir(base_path)
# set env flag
os.environ['TEST_TRANSLATIONS'] = 'True'
@task @task
def render_js_files(c): def render_js_files(c):
""" """