mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-20 22:06:28 +00:00
Merge branch 'master' of https://github.com/inventree/InvenTree into price-history
This commit is contained in:
@ -25,13 +25,13 @@ class PartResource(ModelResource):
|
||||
|
||||
# ForeignKey fields
|
||||
category = Field(attribute='category', widget=widgets.ForeignKeyWidget(PartCategory))
|
||||
|
||||
|
||||
default_location = Field(attribute='default_location', widget=widgets.ForeignKeyWidget(StockLocation))
|
||||
|
||||
default_supplier = Field(attribute='default_supplier', widget=widgets.ForeignKeyWidget(SupplierPart))
|
||||
|
||||
category_name = Field(attribute='category__name', readonly=True)
|
||||
|
||||
|
||||
variant_of = Field(attribute='variant_of', widget=widgets.ForeignKeyWidget(Part))
|
||||
|
||||
suppliers = Field(attribute='supplier_count', readonly=True)
|
||||
@ -73,7 +73,7 @@ class PartResource(ModelResource):
|
||||
|
||||
|
||||
class PartAdmin(ImportExportModelAdmin):
|
||||
|
||||
|
||||
resource_class = PartResource
|
||||
|
||||
list_display = ('full_name', 'description', 'total_stock', 'category')
|
||||
|
@ -41,7 +41,7 @@ class PartCategoryTree(TreeSerializer):
|
||||
model = PartCategory
|
||||
|
||||
queryset = PartCategory.objects.all()
|
||||
|
||||
|
||||
@property
|
||||
def root_url(self):
|
||||
return reverse('part-index')
|
||||
@ -79,7 +79,7 @@ class CategoryList(generics.ListCreateAPIView):
|
||||
pass
|
||||
# Look for top-level categories
|
||||
elif isNull(cat_id):
|
||||
|
||||
|
||||
if not cascade:
|
||||
queryset = queryset.filter(parent=None)
|
||||
|
||||
@ -166,9 +166,9 @@ class CategoryParameters(generics.ListAPIView):
|
||||
parent_categories = category.get_ancestors()
|
||||
for parent in parent_categories:
|
||||
category_list.append(parent.pk)
|
||||
|
||||
|
||||
queryset = queryset.filter(category__in=category_list)
|
||||
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
@ -264,7 +264,7 @@ class PartThumbs(generics.ListAPIView):
|
||||
|
||||
# Get all Parts which have an associated image
|
||||
queryset = queryset.exclude(image='')
|
||||
|
||||
|
||||
return queryset
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
@ -301,7 +301,7 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
|
||||
queryset = Part.objects.all()
|
||||
serializer_class = part_serializers.PartSerializer
|
||||
|
||||
|
||||
starred_parts = None
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
@ -482,7 +482,7 @@ class PartList(generics.ListCreateAPIView):
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
|
||||
|
||||
queryset = part_serializers.PartSerializer.prefetch_queryset(queryset)
|
||||
queryset = part_serializers.PartSerializer.annotate_queryset(queryset)
|
||||
|
||||
@ -576,7 +576,7 @@ class PartList(generics.ListCreateAPIView):
|
||||
if cat_id is None:
|
||||
# No category filtering if category is not specified
|
||||
pass
|
||||
|
||||
|
||||
else:
|
||||
# Category has been specified!
|
||||
if isNull(cat_id):
|
||||
@ -780,10 +780,10 @@ class BomList(generics.ListCreateAPIView):
|
||||
kwargs['sub_part_detail'] = str2bool(self.request.GET.get('sub_part_detail', None))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
|
||||
# Ensure the request context is passed through!
|
||||
kwargs['context'] = self.get_serializer_context()
|
||||
|
||||
|
||||
return self.serializer_class(*args, **kwargs)
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
@ -867,7 +867,7 @@ class BomList(generics.ListCreateAPIView):
|
||||
|
||||
# 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)
|
||||
@ -915,7 +915,7 @@ class BomItemValidate(generics.UpdateAPIView):
|
||||
valid = request.data.get('valid', False)
|
||||
|
||||
instance = self.get_object()
|
||||
|
||||
|
||||
serializer = self.get_serializer(instance, data=request.data, partial=partial)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
@ -949,7 +949,7 @@ part_api_urls = [
|
||||
url(r'^sale-price/', include([
|
||||
url(r'^.*$', PartSalePriceList.as_view(), name='api-part-sale-price-list'),
|
||||
])),
|
||||
|
||||
|
||||
# Base URL for PartParameter API endpoints
|
||||
url(r'^parameter/', include([
|
||||
url(r'^template/$', PartParameterTemplateList.as_view(), name='api-part-param-template-list'),
|
||||
|
@ -43,7 +43,7 @@ class PartConfig(AppConfig):
|
||||
if part.image:
|
||||
url = part.image.thumbnail.name
|
||||
loc = os.path.join(settings.MEDIA_ROOT, url)
|
||||
|
||||
|
||||
if not os.path.exists(loc):
|
||||
logger.info("InvenTree: Generating thumbnail for Part '{p}'".format(p=part.name))
|
||||
try:
|
||||
|
@ -69,7 +69,7 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
|
||||
for item in items:
|
||||
|
||||
item.level = str(int(level))
|
||||
|
||||
|
||||
# Avoid circular BOM references
|
||||
if item.pk in uids:
|
||||
continue
|
||||
@ -79,7 +79,7 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
|
||||
if item.sub_part.assembly:
|
||||
if max_levels is None or level < max_levels:
|
||||
add_items(item.sub_part.bom_items.all().order_by('id'), level + 1)
|
||||
|
||||
|
||||
if cascade:
|
||||
# Cascading (multi-level) BOM
|
||||
|
||||
@ -124,7 +124,7 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
|
||||
parameter_cols[name].update({b_idx: value})
|
||||
except KeyError:
|
||||
parameter_cols[name] = {b_idx: value}
|
||||
|
||||
|
||||
# Add parameter columns to dataset
|
||||
parameter_cols_ordered = OrderedDict(sorted(parameter_cols.items(), key=lambda x: x[0]))
|
||||
add_columns_to_dataset(parameter_cols_ordered, len(bom_items))
|
||||
@ -185,7 +185,7 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
|
||||
# Filter manufacturer parts
|
||||
manufacturer_parts = ManufacturerPart.objects.filter(part__pk=b_part.pk)
|
||||
manufacturer_parts = manufacturer_parts.prefetch_related('supplier_parts')
|
||||
|
||||
|
||||
# Process manufacturer part
|
||||
for manufacturer_idx, manufacturer_part in enumerate(manufacturer_parts):
|
||||
|
||||
@ -250,7 +250,7 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
|
||||
|
||||
# Filter supplier parts
|
||||
manufacturer_parts = ManufacturerPart.objects.filter(part__pk=b_part.pk)
|
||||
|
||||
|
||||
for idx, manufacturer_part in enumerate(manufacturer_parts):
|
||||
|
||||
if manufacturer_part:
|
||||
@ -295,7 +295,7 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
|
||||
|
||||
# Filter supplier parts
|
||||
supplier_parts = SupplierPart.objects.filter(part__pk=b_part.pk)
|
||||
|
||||
|
||||
for idx, supplier_part in enumerate(supplier_parts):
|
||||
|
||||
if supplier_part.supplier:
|
||||
@ -326,7 +326,7 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
|
||||
filename = '{n}_BOM.{fmt}'.format(n=part.full_name, fmt=fmt)
|
||||
|
||||
return DownloadFile(data, filename)
|
||||
|
||||
|
||||
|
||||
class BomUploadManager:
|
||||
""" Class for managing an uploaded BOM file """
|
||||
@ -342,7 +342,7 @@ class BomUploadManager:
|
||||
'Part_IPN',
|
||||
'Part_ID',
|
||||
]
|
||||
|
||||
|
||||
# Fields which would be helpful but are not required
|
||||
OPTIONAL_HEADERS = [
|
||||
'Reference',
|
||||
@ -360,7 +360,7 @@ class BomUploadManager:
|
||||
|
||||
def __init__(self, bom_file):
|
||||
""" Initialize the BomUpload class with a user-uploaded file object """
|
||||
|
||||
|
||||
self.process(bom_file)
|
||||
|
||||
def process(self, bom_file):
|
||||
@ -387,7 +387,7 @@ class BomUploadManager:
|
||||
|
||||
def guess_header(self, header, threshold=80):
|
||||
""" Try to match a header (from the file) to a list of known headers
|
||||
|
||||
|
||||
Args:
|
||||
header - Header name to look for
|
||||
threshold - Match threshold for fuzzy search
|
||||
@ -421,7 +421,7 @@ class BomUploadManager:
|
||||
return matches[0]['header']
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def columns(self):
|
||||
""" Return a list of headers for the thingy """
|
||||
headers = []
|
||||
|
@ -82,7 +82,7 @@
|
||||
tree_id: 2
|
||||
lft: 1
|
||||
rght: 4
|
||||
|
||||
|
||||
- model: part.partcategory
|
||||
pk: 8
|
||||
fields:
|
||||
|
@ -95,11 +95,11 @@ class BomExportForm(forms.Form):
|
||||
parameter_data = forms.BooleanField(label=_("Include Parameter Data"), required=False, initial=False, help_text=_("Include part parameters data in exported BOM"))
|
||||
|
||||
stock_data = forms.BooleanField(label=_("Include Stock Data"), required=False, initial=False, help_text=_("Include part stock data in exported BOM"))
|
||||
|
||||
|
||||
manufacturer_data = forms.BooleanField(label=_("Include Manufacturer Data"), required=False, initial=True, help_text=_("Include part manufacturer data in exported BOM"))
|
||||
|
||||
supplier_data = forms.BooleanField(label=_("Include Supplier Data"), required=False, initial=True, help_text=_("Include part supplier data in exported BOM"))
|
||||
|
||||
|
||||
def get_choices(self):
|
||||
""" BOM export format choices """
|
||||
|
||||
@ -324,7 +324,7 @@ class EditCategoryParameterTemplateForm(HelperForm):
|
||||
add_to_all_categories = forms.BooleanField(required=False,
|
||||
initial=False,
|
||||
help_text=_('Add parameter template to all categories'))
|
||||
|
||||
|
||||
class Meta:
|
||||
model = PartCategoryParameterTemplate
|
||||
fields = [
|
||||
|
24
InvenTree/part/migrations/0065_auto_20210505_2144.py
Normal file
24
InvenTree/part/migrations/0065_auto_20210505_2144.py
Normal file
@ -0,0 +1,24 @@
|
||||
# Generated by Django 3.2 on 2021-05-05 21:44
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('part', '0064_auto_20210404_2016'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='part',
|
||||
name='base_cost',
|
||||
field=models.DecimalField(decimal_places=3, default=0, help_text='Minimum charge (e.g. stocking fee)', max_digits=10, validators=[django.core.validators.MinValueValidator(0)], verbose_name='base cost'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='part',
|
||||
name='multiple',
|
||||
field=models.PositiveIntegerField(default=1, help_text='Sell multiple', validators=[django.core.validators.MinValueValidator(1)], verbose_name='multiple'),
|
||||
),
|
||||
]
|
@ -349,7 +349,7 @@ class Part(MPTTModel):
|
||||
|
||||
context['available'] = self.available_stock
|
||||
context['on_order'] = self.on_order
|
||||
|
||||
|
||||
context['required'] = context['required_build_order_quantity'] + context['required_sales_order_quantity']
|
||||
context['allocated'] = context['allocated_build_order_quantity'] + context['allocated_sales_order_quantity']
|
||||
|
||||
@ -434,7 +434,7 @@ class Part(MPTTModel):
|
||||
a) The parent part is the same as this one
|
||||
b) The parent part is used in the BOM for *this* part
|
||||
c) The parent part is used in the BOM for any child parts under this one
|
||||
|
||||
|
||||
Failing this check raises a ValidationError!
|
||||
|
||||
"""
|
||||
@ -506,7 +506,7 @@ class Part(MPTTModel):
|
||||
|
||||
parts = Part.objects.filter(tree_id=self.tree_id)
|
||||
stock = StockModels.StockItem.objects.filter(part__in=parts).exclude(serial=None)
|
||||
|
||||
|
||||
# There are no matchin StockItem objects (skip further tests)
|
||||
if not stock.exists():
|
||||
return None
|
||||
@ -578,7 +578,7 @@ class Part(MPTTModel):
|
||||
|
||||
if self.IPN:
|
||||
elements.append(self.IPN)
|
||||
|
||||
|
||||
elements.append(self.name)
|
||||
|
||||
if self.revision:
|
||||
@ -663,7 +663,7 @@ class Part(MPTTModel):
|
||||
def clean(self):
|
||||
"""
|
||||
Perform cleaning operations for the Part model
|
||||
|
||||
|
||||
Update trackable status:
|
||||
If this part is trackable, and it is used in the BOM
|
||||
for a parent part which is *not* trackable,
|
||||
@ -946,7 +946,7 @@ class Part(MPTTModel):
|
||||
quantity = 0
|
||||
|
||||
for build in builds:
|
||||
|
||||
|
||||
bom_item = None
|
||||
|
||||
# List the bom lines required to make the build (including inherited ones!)
|
||||
@ -958,7 +958,7 @@ class Part(MPTTModel):
|
||||
build_quantity = build.quantity * bom_item.quantity
|
||||
|
||||
quantity += build_quantity
|
||||
|
||||
|
||||
return quantity
|
||||
|
||||
def requiring_sales_orders(self):
|
||||
@ -1008,7 +1008,7 @@ class Part(MPTTModel):
|
||||
def quantity_to_order(self):
|
||||
"""
|
||||
Return the quantity needing to be ordered for this part.
|
||||
|
||||
|
||||
Here, an "order" could be one of:
|
||||
- Build Order
|
||||
- Sales Order
|
||||
@ -1019,7 +1019,7 @@ class Part(MPTTModel):
|
||||
Required for orders = self.required_order_quantity()
|
||||
Currently on order = self.on_order
|
||||
Currently building = self.quantity_being_built
|
||||
|
||||
|
||||
"""
|
||||
|
||||
# Total requirement
|
||||
@ -1114,7 +1114,7 @@ class Part(MPTTModel):
|
||||
|
||||
if total is None:
|
||||
total = 0
|
||||
|
||||
|
||||
return max(total, 0)
|
||||
|
||||
@property
|
||||
@ -1238,7 +1238,7 @@ class Part(MPTTModel):
|
||||
@property
|
||||
def total_stock(self):
|
||||
""" Return the total stock quantity for this part.
|
||||
|
||||
|
||||
- Part may be stored in multiple locations
|
||||
- If this part is a "template" (variants exist) then these are counted too
|
||||
"""
|
||||
@ -1463,7 +1463,7 @@ class Part(MPTTModel):
|
||||
|
||||
# Start with a list of all parts designated as 'sub components'
|
||||
parts = Part.objects.filter(component=True)
|
||||
|
||||
|
||||
# Exclude this part
|
||||
parts = parts.exclude(id=self.id)
|
||||
|
||||
@ -1496,7 +1496,7 @@ class Part(MPTTModel):
|
||||
|
||||
def get_price_info(self, quantity=1, buy=True, bom=True):
|
||||
""" Return a simplified pricing string for this part
|
||||
|
||||
|
||||
Args:
|
||||
quantity: Number of units to calculate price for
|
||||
buy: Include supplier pricing (default = True)
|
||||
@ -1519,7 +1519,7 @@ class Part(MPTTModel):
|
||||
return "{a} - {b}".format(a=min_price, b=max_price)
|
||||
|
||||
def get_supplier_price_range(self, quantity=1):
|
||||
|
||||
|
||||
min_price = None
|
||||
max_price = None
|
||||
|
||||
@ -1586,7 +1586,7 @@ class Part(MPTTModel):
|
||||
return (min_price, max_price)
|
||||
|
||||
def get_price_range(self, quantity=1, buy=True, bom=True):
|
||||
|
||||
|
||||
""" Return the price range for this part. This price can be either:
|
||||
|
||||
- Supplier price (if purchased from suppliers)
|
||||
@ -1611,6 +1611,44 @@ class Part(MPTTModel):
|
||||
max(buy_price_range[1], bom_price_range[1])
|
||||
)
|
||||
|
||||
base_cost = models.DecimalField(max_digits=10, decimal_places=3, default=0, validators=[MinValueValidator(0)], verbose_name=_('base cost'), help_text=_('Minimum charge (e.g. stocking fee)'))
|
||||
|
||||
multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], verbose_name=_('multiple'), help_text=_('Sell multiple'))
|
||||
|
||||
get_price = common.models.get_price
|
||||
|
||||
@property
|
||||
def has_price_breaks(self):
|
||||
return self.price_breaks.count() > 0
|
||||
|
||||
@property
|
||||
def price_breaks(self):
|
||||
""" Return the associated price breaks in the correct order """
|
||||
return self.salepricebreaks.order_by('quantity').all()
|
||||
|
||||
@property
|
||||
def unit_pricing(self):
|
||||
return self.get_price(1)
|
||||
|
||||
def add_price_break(self, quantity, price):
|
||||
"""
|
||||
Create a new price break for this part
|
||||
|
||||
args:
|
||||
quantity - Numerical quantity
|
||||
price - Must be a Money object
|
||||
"""
|
||||
|
||||
# Check if a price break at that quantity already exists...
|
||||
if self.price_breaks.filter(quantity=quantity, part=self.pk).exists():
|
||||
return
|
||||
|
||||
PartSellPriceBreak.objects.create(
|
||||
part=self,
|
||||
quantity=quantity,
|
||||
price=price
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def copy_bom_from(self, other, clear=True, **kwargs):
|
||||
"""
|
||||
@ -1645,7 +1683,7 @@ class Part(MPTTModel):
|
||||
|
||||
@transaction.atomic
|
||||
def copy_parameters_from(self, other, **kwargs):
|
||||
|
||||
|
||||
clear = kwargs.get('clear', True)
|
||||
|
||||
if clear:
|
||||
@ -1692,7 +1730,7 @@ class Part(MPTTModel):
|
||||
# Copy the parameters data
|
||||
if kwargs.get('parameters', True):
|
||||
self.copy_parameters_from(other)
|
||||
|
||||
|
||||
# Copy the fields that aren't available in the duplicate form
|
||||
self.salable = other.salable
|
||||
self.assembly = other.assembly
|
||||
@ -1722,7 +1760,7 @@ class Part(MPTTModel):
|
||||
tests = tests.filter(required=required)
|
||||
|
||||
return tests
|
||||
|
||||
|
||||
def getRequiredTests(self):
|
||||
# Return the tests which are required by this part
|
||||
return self.getTestTemplates(required=True)
|
||||
@ -1868,7 +1906,7 @@ class PartAttachment(InvenTreeAttachment):
|
||||
"""
|
||||
Model for storing file attachments against a Part object
|
||||
"""
|
||||
|
||||
|
||||
def getSubdir(self):
|
||||
return os.path.join("part_files", str(self.part.id))
|
||||
|
||||
@ -2227,7 +2265,7 @@ class BomItem(models.Model):
|
||||
|
||||
def validate_hash(self, valid=True):
|
||||
""" Mark this item as 'valid' (store the checksum hash).
|
||||
|
||||
|
||||
Args:
|
||||
valid: If true, validate the hash, otherwise invalidate it (default = True)
|
||||
"""
|
||||
@ -2265,7 +2303,7 @@ class BomItem(models.Model):
|
||||
# Check for circular BOM references
|
||||
if self.sub_part:
|
||||
self.sub_part.checkAddToBOM(self.part)
|
||||
|
||||
|
||||
# If the sub_part is 'trackable' then the 'quantity' field must be an integer
|
||||
if self.sub_part.trackable:
|
||||
if not self.quantity == int(self.quantity):
|
||||
@ -2301,7 +2339,7 @@ class BomItem(models.Model):
|
||||
"""
|
||||
|
||||
query = self.sub_part.stock_items.all()
|
||||
|
||||
|
||||
query = query.prefetch_related([
|
||||
'sub_part__stock_items',
|
||||
])
|
||||
@ -2358,7 +2396,7 @@ class BomItem(models.Model):
|
||||
def get_required_quantity(self, build_quantity):
|
||||
""" Calculate the required part quantity, based on the supplier build_quantity.
|
||||
Includes overage estimate in the returned value.
|
||||
|
||||
|
||||
Args:
|
||||
build_quantity: Number of parts to build
|
||||
|
||||
|
@ -134,7 +134,7 @@ class PartBriefSerializer(InvenTreeModelSerializer):
|
||||
""" Serializer for Part (brief detail) """
|
||||
|
||||
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
|
||||
|
||||
|
||||
stock = serializers.FloatField(source='total_stock')
|
||||
|
||||
class Meta:
|
||||
@ -232,7 +232,7 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Filter to limit orders to "open"
|
||||
order_filter = Q(
|
||||
order__status__in=PurchaseOrderStatus.OPEN
|
||||
@ -259,7 +259,7 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
output_field=models.DecimalField(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
return queryset
|
||||
|
||||
def get_starred(self, part):
|
||||
@ -358,7 +358,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
quantity = serializers.FloatField()
|
||||
|
||||
part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(assembly=True))
|
||||
|
||||
|
||||
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
||||
|
||||
sub_part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(component=True))
|
||||
|
@ -46,5 +46,5 @@
|
||||
part: {{ part.id }},
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
{% endblock %}
|
@ -198,7 +198,7 @@
|
||||
})
|
||||
|
||||
$("#part-export").click(function() {
|
||||
|
||||
|
||||
var url = "{% url 'part-export' %}?category={{ category.id }}";
|
||||
|
||||
location.href = url;
|
||||
|
@ -20,20 +20,20 @@
|
||||
<tr>
|
||||
<td><span class='fas fa-font'></span></td>
|
||||
<td><strong>{% trans "Part name" %}</strong></td>
|
||||
<td>{{ part.name }}</td>
|
||||
<td>{{ part.name }}{% include "clip.html"%}</td>
|
||||
</tr>
|
||||
{% if part.IPN %}
|
||||
<tr>
|
||||
<td></td>
|
||||
<td><strong>{% trans "IPN" %}</strong></td>
|
||||
<td>{{ part.IPN }}</td>
|
||||
<td>{{ part.IPN }}{% include "clip.html"%}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if part.revision %}
|
||||
<tr>
|
||||
<td><span class='fas fa-code-branch'></span></td>
|
||||
<td><strong>{% trans "Revision" %}</strong></td>
|
||||
<td>{{ part.revision }}</td>
|
||||
<td>{{ part.revision }}{% include "clip.html"%}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if part.trackable %}
|
||||
@ -42,7 +42,7 @@
|
||||
<td><strong>{% trans "Latest Serial Number" %}</strong></td>
|
||||
<td>
|
||||
{% if part.getLatestSerialNumber %}
|
||||
{{ part.getLatestSerialNumber }}
|
||||
{{ part.getLatestSerialNumber }}{% include "clip.html"%}
|
||||
{% else %}
|
||||
<em>{% trans "No serial numbers recorded" %}</em>
|
||||
{% endif %}
|
||||
@ -52,7 +52,7 @@
|
||||
<tr>
|
||||
<td><span class='fas fa-info-circle'></span></td>
|
||||
<td><strong>{% trans "Description" %}</strong></td>
|
||||
<td>{{ part.description }}</td>
|
||||
<td>{{ part.description }}{% include "clip.html"%}</td>
|
||||
</tr>
|
||||
{% if part.variant_of %}
|
||||
<tr>
|
||||
@ -96,7 +96,7 @@
|
||||
<td></td>
|
||||
<td><strong>{% trans "Default Supplier" %}</strong></td>
|
||||
<td><a href="{% url 'supplier-part-detail' part.default_supplier.id %}">
|
||||
{{ part.default_supplier.supplier.name }} | {{ part.default_supplier.SKU }}
|
||||
{{ part.default_supplier.supplier.name }} | {{ part.default_supplier.SKU }}{% include "clip.html"%}
|
||||
</a></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
@ -262,5 +262,5 @@
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
@ -58,7 +58,7 @@
|
||||
});
|
||||
|
||||
$("#manufacturer-part-delete").click(function() {
|
||||
|
||||
|
||||
var selections = $("#manufacturer-table").bootstrapTable("getSelections");
|
||||
|
||||
var parts = [];
|
||||
|
@ -91,7 +91,7 @@
|
||||
{% if part.salable and roles.sales_order.view %}
|
||||
<li class='list-group-item {% if tab == "sales-prices" %}active{% endif %}' title='{% trans "Sales Price Information" %}'>
|
||||
<a href='{% url "part-sale-prices" part.id %}'>
|
||||
<span class='menu-tab-icon fas fa-dollar-sign'></span>
|
||||
<span class='menu-tab-icon fas fa-dollar-sign' style='width: 20px;'></span>
|
||||
{% trans "Sale Price" %}
|
||||
</a>
|
||||
</li>
|
||||
|
@ -20,12 +20,12 @@
|
||||
{% if editing %}
|
||||
<form method='POST'>
|
||||
{% csrf_token %}
|
||||
|
||||
|
||||
{{ form }}
|
||||
<hr>
|
||||
|
||||
<button type="submit" class='btn btn-default'>{% trans "Save" %}</button>
|
||||
|
||||
|
||||
</form>
|
||||
|
||||
{{ form.media }}
|
||||
|
@ -49,7 +49,7 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
@ -151,7 +151,7 @@
|
||||
<td>{% decimal allocated %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if not part.is_template %}
|
||||
{% if part.assembly %}
|
||||
<tr>
|
||||
@ -177,11 +177,11 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class='panel panel-default panel-inventree'>
|
||||
|
||||
|
||||
<div class='panel-heading'>
|
||||
<h4>
|
||||
{% block heading %}
|
||||
@ -272,7 +272,7 @@
|
||||
function onSelectImage(response) {
|
||||
// Callback when the image-selection modal form is displayed
|
||||
// Populate the form with image data (requested via AJAX)
|
||||
|
||||
|
||||
$("#modal-form").find("#image-select-table").bootstrapTable({
|
||||
pagination: true,
|
||||
pageSize: 25,
|
||||
@ -301,9 +301,9 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
{% if roles.part.change %}
|
||||
|
||||
|
||||
{% settings_value "INVENTREE_DOWNLOAD_FROM_URL" as allow_download %}
|
||||
{% if allow_download %}
|
||||
$("#part-image-url").click(function() {
|
||||
|
@ -9,19 +9,20 @@
|
||||
</div>
|
||||
|
||||
<h4>{% trans 'Quantity' %}</h4>
|
||||
<table class='table table-striped table-condensed'>
|
||||
<table class='table table-striped table-condensed table-price-two'>
|
||||
<tr>
|
||||
<td><b>{% trans 'Part' %}</b></td>
|
||||
<td colspan='2'>{{ part }}</td>
|
||||
<td>{{ part }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>{% trans 'Quantity' %}</b></td>
|
||||
<td colspan='2'>{{ quantity }}</td>
|
||||
<td>{{ quantity }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% if part.supplier_count > 0 %}
|
||||
|
||||
{% if part.supplier_count > 0 %}
|
||||
<h4>{% trans 'Supplier Pricing' %}</h4>
|
||||
<table class='table table-striped table-condensed'>
|
||||
<table class='table table-striped table-condensed table-price-three'>
|
||||
{% if min_total_buy_price %}
|
||||
<tr>
|
||||
<td><b>{% trans 'Unit Cost' %}</b></td>
|
||||
@ -42,12 +43,12 @@
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
{% endif %}
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if part.bom_count > 0 %}
|
||||
{% if part.bom_count > 0 %}
|
||||
<h4>{% trans 'BOM Pricing' %}</h4>
|
||||
<table class='table table-striped table-condensed'>
|
||||
<table class='table table-striped table-condensed table-price-three'>
|
||||
{% if min_total_bom_price %}
|
||||
<tr>
|
||||
<td><b>{% trans 'Unit Cost' %}</b></td>
|
||||
@ -75,8 +76,22 @@
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
{% endif %}
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if total_part_price %}
|
||||
<h4>{% trans 'Sale Price' %}</h4>
|
||||
<table class='table table-striped table-condensed table-price-two'>
|
||||
<tr>
|
||||
<td><b>{% trans 'Unit Cost' %}</b></td>
|
||||
<td>{% include "price.html" with price=unit_part_price %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>{% trans 'Total Cost' %}</b></td>
|
||||
<td>{% include "price.html" with price=total_part_price %}</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if min_unit_buy_price or min_unit_bom_price %}
|
||||
{% else %}
|
||||
|
@ -2,7 +2,7 @@
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block menubar %}}
|
||||
{% block menubar %}
|
||||
{% include 'part/navbar.html' with tab='sales-prices' %}
|
||||
{% endblock %}
|
||||
|
||||
|
@ -11,7 +11,7 @@
|
||||
<form method='post' action='' class='js-modal-form' enctype='multipart/form-data'>
|
||||
{% csrf_token %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
|
||||
<input id='image-input' name='image' type='hidden' value="{{ part.image }}">
|
||||
|
||||
<table id='image-select-table' class='table table-striped table-condensed table-img-grid'>
|
||||
|
@ -4,10 +4,10 @@
|
||||
{% block form %}
|
||||
<form method="post" action='' class='js-modal-form' enctype="multipart/form-data">
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
|
||||
<label class='control-label'>Parts</label>
|
||||
<p class='help-block'>{% trans "Set category for the following parts" %}</p>
|
||||
|
||||
|
||||
<table class='table table-striped'>
|
||||
<tr>
|
||||
<th>{% trans "Part" %}</th>
|
||||
@ -36,8 +36,8 @@
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
|
||||
{% crispy form %}
|
||||
|
||||
|
||||
</form>
|
||||
{% endblock %}
|
@ -11,7 +11,7 @@
|
||||
{% block category_content %}
|
||||
|
||||
<div class='panel panel-default panel-inventree'>
|
||||
|
||||
|
||||
<div class='panel-heading'>
|
||||
<h4>{% trans "Subcategories" %}</h4>
|
||||
</div>
|
||||
|
@ -62,7 +62,7 @@
|
||||
});
|
||||
|
||||
$("#supplier-part-delete").click(function() {
|
||||
|
||||
|
||||
var selections = $("#supplier-table").bootstrapTable("getSelections");
|
||||
|
||||
var parts = [];
|
||||
|
@ -47,7 +47,7 @@ def str2bool(x, *args, **kwargs):
|
||||
def inrange(n, *args, **kwargs):
|
||||
""" Return range(n) for iterating through a numeric quantity """
|
||||
return range(n)
|
||||
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def multiply(x, y, *args, **kwargs):
|
||||
@ -59,7 +59,7 @@ def multiply(x, y, *args, **kwargs):
|
||||
def add(x, y, *args, **kwargs):
|
||||
""" Add two numbers together """
|
||||
return x + y
|
||||
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def part_allocation_count(build, part, *args, **kwargs):
|
||||
@ -177,7 +177,7 @@ def authorized_owners(group):
|
||||
except TypeError:
|
||||
# group.get_users returns None
|
||||
pass
|
||||
|
||||
|
||||
return owners
|
||||
|
||||
|
||||
@ -200,18 +200,28 @@ class I18nStaticNode(StaticNode):
|
||||
return ret
|
||||
|
||||
|
||||
@register.tag('i18n_static')
|
||||
def do_i18n_static(parser, token):
|
||||
"""
|
||||
Overrides normal static, adds language - lookup for prerenderd files #1485
|
||||
# use the dynamic url - tag if in Debugging-Mode
|
||||
if settings.DEBUG:
|
||||
|
||||
usage (like static):
|
||||
{% i18n_static path [as varname] %}
|
||||
"""
|
||||
bits = token.split_contents()
|
||||
loc_name = settings.STATICFILES_I18_PREFIX
|
||||
@register.simple_tag()
|
||||
def i18n_static(url_name):
|
||||
""" simple tag to enable {% url %} functionality instead of {% static %} """
|
||||
return reverse(url_name)
|
||||
|
||||
# change path to called ressource
|
||||
bits[1] = f"'{loc_name}/{{lng}}.{bits[1][1:-1]}'"
|
||||
token.contents = ' '.join(bits)
|
||||
return I18nStaticNode.handle_token(parser, token)
|
||||
else:
|
||||
|
||||
@register.tag('i18n_static')
|
||||
def do_i18n_static(parser, token):
|
||||
"""
|
||||
Overrides normal static, adds language - lookup for prerenderd files #1485
|
||||
|
||||
usage (like static):
|
||||
{% i18n_static path [as varname] %}
|
||||
"""
|
||||
bits = token.split_contents()
|
||||
loc_name = settings.STATICFILES_I18_PREFIX
|
||||
|
||||
# change path to called ressource
|
||||
bits[1] = f"'{loc_name}/{{lng}}.{bits[1][1:-1]}'"
|
||||
token.contents = ' '.join(bits)
|
||||
return I18nStaticNode.handle_token(parser, token)
|
||||
|
@ -41,12 +41,12 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
Test that we can retrieve list of part categories,
|
||||
with various filtering options.
|
||||
"""
|
||||
|
||||
|
||||
url = reverse('api-part-category-list')
|
||||
|
||||
|
||||
# Request *all* part categories
|
||||
response = self.client.get(url, format='json')
|
||||
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 8)
|
||||
|
||||
@ -95,7 +95,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
url = reverse('api-part-category-list')
|
||||
response = self.client.post(url, data, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
parent = response.data['pk']
|
||||
|
||||
# Add some sub-categories to the top-level 'Animals' category
|
||||
@ -289,7 +289,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
|
||||
self.assertIn('count', data)
|
||||
self.assertIn('results', data)
|
||||
|
||||
|
||||
self.assertEqual(len(data['results']), n)
|
||||
|
||||
|
||||
@ -354,7 +354,7 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
|
||||
|
||||
self.assertEqual(data['in_stock'], 600)
|
||||
self.assertEqual(data['stock_item_count'], 4)
|
||||
|
||||
|
||||
# Add some more stock items!!
|
||||
for i in range(100):
|
||||
StockItem.objects.create(part=self.part, quantity=5)
|
||||
@ -463,7 +463,7 @@ class PartParameterTest(InvenTreeAPITestCase):
|
||||
response = self.client.patch(url, {'data': '15'}, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
# Check that the data changed!
|
||||
response = self.client.get(url, format='json')
|
||||
|
||||
|
@ -64,7 +64,7 @@ class BomItemTest(TestCase):
|
||||
""" Test that BOM line overages are calculated correctly """
|
||||
|
||||
item = BomItem.objects.get(part=100, sub_part=50)
|
||||
|
||||
|
||||
q = 300
|
||||
|
||||
item.quantity = q
|
||||
@ -77,7 +77,7 @@ class BomItemTest(TestCase):
|
||||
item.overage = 'asf234?'
|
||||
n = item.get_overage_quantity(q)
|
||||
self.assertEqual(n, 0)
|
||||
|
||||
|
||||
# Test absolute overage
|
||||
item.overage = '3'
|
||||
n = item.get_overage_quantity(q)
|
||||
@ -100,7 +100,7 @@ class BomItemTest(TestCase):
|
||||
""" Test BOM item hash encoding """
|
||||
|
||||
item = BomItem.objects.get(part=100, sub_part=50)
|
||||
|
||||
|
||||
h1 = item.get_item_hash()
|
||||
|
||||
# Change data - the hash must change
|
||||
|
@ -59,7 +59,7 @@ class CategoryTest(TestCase):
|
||||
|
||||
def test_unique_parents(self):
|
||||
""" Test the 'unique_parents' functionality """
|
||||
|
||||
|
||||
parents = [item.pk for item in self.transceivers.getUniqueParents()]
|
||||
|
||||
self.assertIn(self.electronics.id, parents)
|
||||
@ -128,9 +128,9 @@ class CategoryTest(TestCase):
|
||||
with self.assertRaises(ValidationError) as err:
|
||||
cat.full_clean()
|
||||
cat.save()
|
||||
|
||||
|
||||
self.assertIn('Illegal character in name', str(err.exception.error_dict.get('name')))
|
||||
|
||||
|
||||
cat.name = 'good name'
|
||||
cat.save()
|
||||
|
||||
|
@ -34,7 +34,7 @@ class TestForwardMigrations(MigratorTestCase):
|
||||
# Initially some fields are not present
|
||||
with self.assertRaises(AttributeError):
|
||||
print(p.has_variants)
|
||||
|
||||
|
||||
with self.assertRaises(AttributeError):
|
||||
print(p.is_template)
|
||||
|
||||
|
@ -32,7 +32,7 @@ class TestParams(TestCase):
|
||||
self.assertEqual(str(c1), 'Mechanical | Length | 2.8')
|
||||
|
||||
def test_validate(self):
|
||||
|
||||
|
||||
n = PartParameterTemplate.objects.all().count()
|
||||
|
||||
t1 = PartParameterTemplate(name='abcde', units='dd')
|
||||
|
@ -91,7 +91,7 @@ class PartTest(TestCase):
|
||||
def test_rename_img(self):
|
||||
img = rename_part_image(self.r1, 'hello.png')
|
||||
self.assertEqual(img, os.path.join('part_images', 'hello.png'))
|
||||
|
||||
|
||||
def test_stock(self):
|
||||
# No stock of any resistors
|
||||
res = Part.objects.filter(description__contains='resistor')
|
||||
@ -178,7 +178,7 @@ class PartSettingsTest(TestCase):
|
||||
|
||||
Some fields for the Part model can have default values specified by the user.
|
||||
"""
|
||||
|
||||
|
||||
def setUp(self):
|
||||
# Create a user for auth
|
||||
user = get_user_model()
|
||||
@ -252,7 +252,7 @@ class PartSettingsTest(TestCase):
|
||||
self.assertEqual(part.trackable, val)
|
||||
self.assertEqual(part.assembly, val)
|
||||
self.assertEqual(part.is_template, val)
|
||||
|
||||
|
||||
Part.objects.filter(pk=part.pk).delete()
|
||||
|
||||
def test_duplicate_ipn(self):
|
||||
|
@ -9,7 +9,7 @@ from .models import Part, PartRelated
|
||||
|
||||
|
||||
class PartViewTestCase(TestCase):
|
||||
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
'part',
|
||||
@ -24,7 +24,7 @@ class PartViewTestCase(TestCase):
|
||||
|
||||
# Create a user
|
||||
user = get_user_model()
|
||||
|
||||
|
||||
self.user = user.objects.create_user(
|
||||
username='username',
|
||||
email='user@email.com',
|
||||
@ -52,12 +52,12 @@ class PartListTest(PartViewTestCase):
|
||||
def test_part_index(self):
|
||||
response = self.client.get(reverse('part-index'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
keys = response.context.keys()
|
||||
self.assertIn('csrf_token', keys)
|
||||
self.assertIn('parts', keys)
|
||||
self.assertIn('user', keys)
|
||||
|
||||
|
||||
def test_export(self):
|
||||
""" Export part data to CSV """
|
||||
|
||||
@ -153,7 +153,7 @@ class PartDetailTest(PartViewTestCase):
|
||||
response = self.client.get(reverse('bom-download', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('streaming_content', dir(response))
|
||||
|
||||
|
||||
|
||||
class PartTests(PartViewTestCase):
|
||||
""" Tests for Part forms """
|
||||
@ -226,7 +226,7 @@ class PartRelatedTests(PartViewTestCase):
|
||||
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)
|
||||
@ -266,7 +266,7 @@ class PartQRTest(PartViewTestCase):
|
||||
def test_valid_part(self):
|
||||
response = self.client.get(reverse('part-qr', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
data = str(response.content)
|
||||
|
||||
self.assertIn('Part QR Code', data)
|
||||
|
@ -30,11 +30,10 @@ sale_price_break_urls = [
|
||||
]
|
||||
|
||||
part_parameter_urls = [
|
||||
|
||||
url(r'^template/new/', views.PartParameterTemplateCreate.as_view(), name='part-param-template-create'),
|
||||
url(r'^template/(?P<pk>\d+)/edit/', views.PartParameterTemplateEdit.as_view(), name='part-param-template-edit'),
|
||||
url(r'^template/(?P<pk>\d+)/delete/', views.PartParameterTemplateDelete.as_view(), name='part-param-template-edit'),
|
||||
|
||||
|
||||
url(r'^new/', views.PartParameterCreate.as_view(), name='part-param-create'),
|
||||
url(r'^(?P<pk>\d+)/edit/', views.PartParameterEdit.as_view(), name='part-param-edit'),
|
||||
url(r'^(?P<pk>\d+)/delete/', views.PartParameterDelete.as_view(), name='part-param-delete'),
|
||||
@ -49,10 +48,10 @@ part_detail_urls = [
|
||||
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'),
|
||||
url(r'^bom-duplicate/?', views.BomDuplicate.as_view(), name='duplicate-bom'),
|
||||
|
||||
|
||||
url(r'^params/', views.PartDetail.as_view(template_name='part/params.html'), name='part-params'),
|
||||
url(r'^variants/?', views.PartDetail.as_view(template_name='part/variants.html'), name='part-variants'),
|
||||
url(r'^stock/?', views.PartDetail.as_view(template_name='part/stock.html'), name='part-stock'),
|
||||
@ -70,7 +69,7 @@ part_detail_urls = [
|
||||
url(r'^related-parts/?', views.PartDetail.as_view(template_name='part/related.html'), name='part-related'),
|
||||
url(r'^attachments/?', views.PartDetail.as_view(template_name='part/attachments.html'), name='part-attachments'),
|
||||
url(r'^notes/?', views.PartNotes.as_view(), name='part-notes'),
|
||||
|
||||
|
||||
url(r'^qr_code/?', views.PartQRCode.as_view(), name='part-qr'),
|
||||
|
||||
# Normal thumbnail with form
|
||||
@ -104,7 +103,7 @@ category_urls = [
|
||||
|
||||
url(r'^subcategory/', views.CategoryDetail.as_view(template_name='part/subcategory.html'), name='category-subcategory'),
|
||||
url(r'^parametric/', views.CategoryParametric.as_view(), name='category-parametric'),
|
||||
|
||||
|
||||
# Anything else
|
||||
url(r'^.*$', views.CategoryDetail.as_view(), name='category-detail'),
|
||||
]))
|
||||
|
@ -206,12 +206,12 @@ class PartAttachmentCreate(AjaxCreateView):
|
||||
|
||||
class PartAttachmentEdit(AjaxUpdateView):
|
||||
""" View for editing a PartAttachment object """
|
||||
|
||||
|
||||
model = PartAttachment
|
||||
form_class = part_forms.EditPartAttachmentForm
|
||||
ajax_template_name = 'modal_form.html'
|
||||
ajax_form_title = _('Edit attachment')
|
||||
|
||||
|
||||
def get_data(self):
|
||||
return {
|
||||
'success': _('Part attachment updated')
|
||||
@ -247,7 +247,7 @@ class PartTestTemplateCreate(AjaxCreateView):
|
||||
model = PartTestTemplate
|
||||
form_class = part_forms.EditPartTestTemplateForm
|
||||
ajax_form_title = _("Create Test Template")
|
||||
|
||||
|
||||
def get_initial(self):
|
||||
|
||||
initials = super().get_initial()
|
||||
@ -301,7 +301,7 @@ class PartSetCategory(AjaxUpdateView):
|
||||
|
||||
category = None
|
||||
parts = []
|
||||
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
""" Respond to a GET request to this view """
|
||||
|
||||
@ -366,7 +366,7 @@ class PartSetCategory(AjaxUpdateView):
|
||||
ctx['category'] = self.category
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
|
||||
class MakePartVariant(AjaxCreateView):
|
||||
""" View for creating a new variant based on an existing template Part
|
||||
@ -503,17 +503,17 @@ class PartDuplicate(AjaxCreateView):
|
||||
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))
|
||||
|
||||
@ -567,7 +567,7 @@ class PartDuplicate(AjaxCreateView):
|
||||
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
|
||||
@ -577,7 +577,7 @@ class PartCreate(AjaxCreateView):
|
||||
""" View for creating a new Part object.
|
||||
|
||||
Options for providing initial conditions:
|
||||
|
||||
|
||||
- Provide a category object as initial data
|
||||
"""
|
||||
model = Part
|
||||
@ -638,9 +638,9 @@ class PartCreate(AjaxCreateView):
|
||||
context = {}
|
||||
|
||||
valid = form.is_valid()
|
||||
|
||||
|
||||
name = request.POST.get('name', None)
|
||||
|
||||
|
||||
if name:
|
||||
matches = match_part_names(name)
|
||||
|
||||
@ -648,17 +648,17 @@ class PartCreate(AjaxCreateView):
|
||||
|
||||
# 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
|
||||
|
||||
@ -707,7 +707,7 @@ class PartCreate(AjaxCreateView):
|
||||
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:
|
||||
@ -736,7 +736,7 @@ class PartNotes(UpdateView):
|
||||
|
||||
def get_success_url(self):
|
||||
""" Return the success URL for this form """
|
||||
|
||||
|
||||
return reverse('part-notes', kwargs={'pk': self.get_object().id})
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
@ -769,7 +769,7 @@ class PartDetail(InvenTreeRoleMixin, DetailView):
|
||||
- If '?editing=True', set 'editing_enabled' context variable
|
||||
"""
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
|
||||
part = self.get_object()
|
||||
|
||||
if str2bool(self.request.GET.get('edit', '')):
|
||||
@ -808,7 +808,7 @@ class PartDetailFromIPN(PartDetail):
|
||||
pass
|
||||
except queryset.model.DoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
return None
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
@ -886,7 +886,7 @@ class PartImageDownloadFromURL(AjaxUpdateView):
|
||||
|
||||
# Check for valid response code
|
||||
if not response.status_code == 200:
|
||||
form.add_error('url', f"{_('Invalid response')}: {response.status_code}")
|
||||
form.add_error('url', _('Invalid response: {code}').format(code=response.status_code))
|
||||
return
|
||||
|
||||
response.raw.decode_content = True
|
||||
@ -1019,7 +1019,7 @@ class BomDuplicate(AjaxUpdateView):
|
||||
ajax_form_title = _('Duplicate BOM')
|
||||
ajax_template_name = 'part/bom_duplicate.html'
|
||||
form_class = part_forms.BomDuplicateForm
|
||||
|
||||
|
||||
def get_form(self):
|
||||
|
||||
form = super().get_form()
|
||||
@ -1220,7 +1220,7 @@ class BomUpload(InvenTreeRoleMixin, FormView):
|
||||
|
||||
def handleBomFileUpload(self):
|
||||
""" Process a BOM file upload form.
|
||||
|
||||
|
||||
This function validates that the uploaded file was valid,
|
||||
and contains tabulated data that can be extracted.
|
||||
If the file does not satisfy these requirements,
|
||||
@ -1301,13 +1301,13 @@ class BomUpload(InvenTreeRoleMixin, FormView):
|
||||
- If using the Part_ID field, we can do an exact match against the PK field
|
||||
- If using the Part_IPN field, we can do an exact match against the IPN field
|
||||
- If using the Part_Name field, we can use fuzzy string matching to match "close" values
|
||||
|
||||
|
||||
We also extract other information from the row, for the other non-matched fields:
|
||||
- Quantity
|
||||
- Reference
|
||||
- Overage
|
||||
- Note
|
||||
|
||||
|
||||
"""
|
||||
|
||||
# Initially use a quantity of zero
|
||||
@ -1377,7 +1377,7 @@ class BomUpload(InvenTreeRoleMixin, FormView):
|
||||
# Check if there is a column corresponding to "Note" field
|
||||
if n_idx >= 0:
|
||||
row['note'] = row['data'][n_idx]
|
||||
|
||||
|
||||
# Supply list of part options for each row, sorted by how closely they match the part name
|
||||
row['part_options'] = part_options
|
||||
|
||||
@ -1392,7 +1392,7 @@ class BomUpload(InvenTreeRoleMixin, FormView):
|
||||
try:
|
||||
if row['part_ipn']:
|
||||
part_matches = [part for part in self.allowed_parts if part.IPN and row['part_ipn'].lower() == str(part.IPN.lower())]
|
||||
|
||||
|
||||
# Check for single match
|
||||
if len(part_matches) == 1:
|
||||
row['part_match'] = part_matches[0]
|
||||
@ -1466,7 +1466,7 @@ class BomUpload(InvenTreeRoleMixin, FormView):
|
||||
col_id = int(s[3])
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
|
||||
if row_id not in self.row_data:
|
||||
self.row_data[row_id] = {}
|
||||
|
||||
@ -1532,7 +1532,7 @@ class BomUpload(InvenTreeRoleMixin, FormView):
|
||||
if col in self.column_selections.values():
|
||||
part_match_found = True
|
||||
break
|
||||
|
||||
|
||||
# If not, notify user
|
||||
if not part_match_found:
|
||||
for col in BomUploadManager.PART_MATCH_HEADERS:
|
||||
@ -1548,7 +1548,7 @@ class BomUpload(InvenTreeRoleMixin, FormView):
|
||||
self.getTableDataFromPost()
|
||||
|
||||
valid = len(self.missing_columns) == 0 and not self.duplicates
|
||||
|
||||
|
||||
if valid:
|
||||
# Try to extract meaningful data
|
||||
self.preFillSelections()
|
||||
@ -1559,7 +1559,7 @@ class BomUpload(InvenTreeRoleMixin, FormView):
|
||||
return self.render_to_response(self.get_context_data(form=None))
|
||||
|
||||
def handlePartSelection(self):
|
||||
|
||||
|
||||
# Extract basic table data from POST request
|
||||
self.getTableDataFromPost()
|
||||
|
||||
@ -1597,7 +1597,7 @@ class BomUpload(InvenTreeRoleMixin, FormView):
|
||||
row['errors']['quantity'] = _('Enter a valid quantity')
|
||||
|
||||
row['quantity'] = q
|
||||
|
||||
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
@ -1650,7 +1650,7 @@ class BomUpload(InvenTreeRoleMixin, FormView):
|
||||
if key.startswith(field + '_'):
|
||||
try:
|
||||
row_id = int(key.replace(field + '_', ''))
|
||||
|
||||
|
||||
row = self.getRowByIndex(row_id)
|
||||
|
||||
if row:
|
||||
@ -1716,7 +1716,7 @@ class BomUpload(InvenTreeRoleMixin, FormView):
|
||||
return self.render_to_response(ctx)
|
||||
|
||||
def getRowByIndex(self, idx):
|
||||
|
||||
|
||||
for row in self.bom_rows:
|
||||
if row['index'] == idx:
|
||||
return row
|
||||
@ -1734,7 +1734,7 @@ class BomUpload(InvenTreeRoleMixin, FormView):
|
||||
self.form = self.get_form(self.get_form_class())
|
||||
|
||||
# Did the user POST a file named bom_file?
|
||||
|
||||
|
||||
form_step = request.POST.get('form_step', None)
|
||||
|
||||
if form_step == 'select_file':
|
||||
@ -1755,7 +1755,7 @@ class PartExport(AjaxView):
|
||||
def get_parts(self, request):
|
||||
""" Extract part list from the POST parameters.
|
||||
Parts can be supplied as:
|
||||
|
||||
|
||||
- Part category
|
||||
- List of part PK values
|
||||
"""
|
||||
@ -1958,10 +1958,9 @@ class PartPricing(AjaxView):
|
||||
form_class = part_forms.PartPriceForm
|
||||
|
||||
role_required = ['sales_order.view', 'part.view']
|
||||
|
||||
|
||||
def get_quantity(self):
|
||||
""" Return set quantity in decimal format """
|
||||
|
||||
return Decimal(self.request.POST.get('quantity', 1))
|
||||
|
||||
def get_part(self):
|
||||
@ -1971,12 +1970,7 @@ class PartPricing(AjaxView):
|
||||
return None
|
||||
|
||||
def get_pricing(self, quantity=1, currency=None):
|
||||
|
||||
# try:
|
||||
# quantity = int(quantity)
|
||||
# except ValueError:
|
||||
# quantity = 1
|
||||
|
||||
""" returns context with pricing information """
|
||||
if quantity <= 0:
|
||||
quantity = 1
|
||||
|
||||
@ -1987,7 +1981,7 @@ class PartPricing(AjaxView):
|
||||
scaler = Decimal(1.0)
|
||||
|
||||
part = self.get_part()
|
||||
|
||||
|
||||
ctx = {
|
||||
'part': part,
|
||||
'quantity': quantity,
|
||||
@ -2041,7 +2035,7 @@ class PartPricing(AjaxView):
|
||||
if min_bom_price:
|
||||
ctx['min_total_bom_price'] = min_bom_price
|
||||
ctx['min_unit_bom_price'] = min_unit_bom_price
|
||||
|
||||
|
||||
if max_bom_price:
|
||||
ctx['max_total_bom_price'] = max_bom_price
|
||||
ctx['max_unit_bom_price'] = max_unit_bom_price
|
||||
@ -2077,12 +2071,22 @@ class PartPricing(AjaxView):
|
||||
ret.append(line)
|
||||
|
||||
ctx['price_history'] = ret
|
||||
# part pricing information
|
||||
part_price = part.get_price(quantity)
|
||||
if part_price is not None:
|
||||
ctx['total_part_price'] = round(part_price, 3)
|
||||
ctx['unit_part_price'] = round(part_price / quantity, 3)
|
||||
|
||||
return ctx
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
def get_initials(self):
|
||||
""" returns initials for form """
|
||||
return {'quantity': self.get_quantity()}
|
||||
|
||||
return self.renderJsonResponse(request, self.form_class(), context=self.get_pricing())
|
||||
def get(self, request, *args, **kwargs):
|
||||
init = self.get_initials()
|
||||
qty = self.get_quantity()
|
||||
return self.renderJsonResponse(request, self.form_class(initial=init), context=self.get_pricing(qty))
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
||||
@ -2091,16 +2095,19 @@ class PartPricing(AjaxView):
|
||||
quantity = self.get_quantity()
|
||||
|
||||
# Retain quantity value set by user
|
||||
form = self.form_class()
|
||||
form.fields['quantity'].initial = quantity
|
||||
form = self.form_class(initial=self.get_initials())
|
||||
|
||||
# TODO - How to handle pricing in different currencies?
|
||||
currency = None
|
||||
|
||||
# check if data is set
|
||||
try:
|
||||
data = self.data
|
||||
except AttributeError:
|
||||
data = {}
|
||||
|
||||
# Always mark the form as 'invalid' (the user may wish to keep getting pricing data)
|
||||
data = {
|
||||
'form_valid': False,
|
||||
}
|
||||
data['form_valid'] = False
|
||||
|
||||
return self.renderJsonResponse(request, form, data=data, context=self.get_pricing(quantity, currency))
|
||||
|
||||
@ -2202,7 +2209,7 @@ class PartParameterDelete(AjaxDeleteView):
|
||||
model = PartParameter
|
||||
ajax_template_name = 'part/param_delete.html'
|
||||
ajax_form_title = _('Delete Part Parameter')
|
||||
|
||||
|
||||
|
||||
class CategoryDetail(InvenTreeRoleMixin, DetailView):
|
||||
""" Detail view for PartCategory """
|
||||
@ -2257,7 +2264,7 @@ class CategoryEdit(AjaxUpdateView):
|
||||
"""
|
||||
Update view to edit a PartCategory
|
||||
"""
|
||||
|
||||
|
||||
model = PartCategory
|
||||
form_class = part_forms.EditCategoryForm
|
||||
ajax_template_name = 'modal_form.html'
|
||||
@ -2278,9 +2285,9 @@ class CategoryEdit(AjaxUpdateView):
|
||||
|
||||
Limit the choices for 'parent' field to those which make sense
|
||||
"""
|
||||
|
||||
|
||||
form = super(AjaxUpdateView, self).get_form()
|
||||
|
||||
|
||||
category = self.get_object()
|
||||
|
||||
# Remove any invalid choices for the parent category part
|
||||
@ -2296,7 +2303,7 @@ class CategoryDelete(AjaxDeleteView):
|
||||
"""
|
||||
Delete view to delete a PartCategory
|
||||
"""
|
||||
|
||||
|
||||
model = PartCategory
|
||||
ajax_template_name = 'part/category_delete.html'
|
||||
ajax_form_title = _('Delete Part Category')
|
||||
@ -2380,7 +2387,7 @@ class CategoryParameterTemplateCreate(AjaxCreateView):
|
||||
"""
|
||||
|
||||
form = super(AjaxCreateView, self).get_form()
|
||||
|
||||
|
||||
form.fields['category'].widget = HiddenInput()
|
||||
|
||||
if form.is_valid():
|
||||
@ -2475,7 +2482,7 @@ class CategoryParameterTemplateEdit(AjaxUpdateView):
|
||||
"""
|
||||
|
||||
form = super(AjaxUpdateView, self).get_form()
|
||||
|
||||
|
||||
form.fields['category'].widget = HiddenInput()
|
||||
form.fields['add_to_all_categories'].widget = HiddenInput()
|
||||
form.fields['add_to_same_level_categories'].widget = HiddenInput()
|
||||
@ -2529,7 +2536,7 @@ class BomItemCreate(AjaxCreateView):
|
||||
"""
|
||||
Create view for making a new BomItem object
|
||||
"""
|
||||
|
||||
|
||||
model = BomItem
|
||||
form_class = part_forms.EditBomItemForm
|
||||
ajax_template_name = 'modal_form.html'
|
||||
@ -2557,13 +2564,13 @@ class BomItemCreate(AjaxCreateView):
|
||||
|
||||
try:
|
||||
part = Part.objects.get(id=part_id)
|
||||
|
||||
|
||||
# Hide the 'part' field
|
||||
form.fields['part'].widget = HiddenInput()
|
||||
|
||||
# Exclude the part from its own BOM
|
||||
sub_part_query = sub_part_query.exclude(id=part.id)
|
||||
|
||||
|
||||
# Eliminate any options that are already in the BOM!
|
||||
sub_part_query = sub_part_query.exclude(id__in=[item.id for item in part.getRequiredParts()])
|
||||
|
||||
@ -2668,7 +2675,7 @@ class PartSalePriceBreakCreate(AjaxCreateView):
|
||||
model = PartSellPriceBreak
|
||||
form_class = part_forms.EditPartSalePriceBreakForm
|
||||
ajax_form_title = _('Add Price Break')
|
||||
|
||||
|
||||
def get_data(self):
|
||||
return {
|
||||
'success': _('Added new price break')
|
||||
@ -2679,7 +2686,7 @@ class PartSalePriceBreakCreate(AjaxCreateView):
|
||||
part = Part.objects.get(id=self.request.GET.get('part'))
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
part = None
|
||||
|
||||
|
||||
if part is None:
|
||||
try:
|
||||
part = Part.objects.get(id=self.request.POST.get('part'))
|
||||
@ -2724,7 +2731,7 @@ class PartSalePriceBreakEdit(AjaxUpdateView):
|
||||
|
||||
return form
|
||||
|
||||
|
||||
|
||||
class PartSalePriceBreakDelete(AjaxDeleteView):
|
||||
""" View for deleting a sale price break """
|
||||
|
||||
|
Reference in New Issue
Block a user