2
0
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:
2021-05-11 13:32:14 +02:00
191 changed files with 5621 additions and 4631 deletions

View File

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

View File

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

View File

@ -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:

View File

@ -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 = []

View File

@ -82,7 +82,7 @@
tree_id: 2
lft: 1
rght: 4
- model: part.partcategory
pk: 8
fields:

View File

@ -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 = [

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

View File

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

View File

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

View File

@ -46,5 +46,5 @@
part: {{ part.id }},
}
});
{% endblock %}

View File

@ -198,7 +198,7 @@
})
$("#part-export").click(function() {
var url = "{% url 'part-export' %}?category={{ category.id }}";
location.href = url;

View File

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

View File

@ -58,7 +58,7 @@
});
$("#manufacturer-part-delete").click(function() {
var selections = $("#manufacturer-table").bootstrapTable("getSelections");
var parts = [];

View File

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

View File

@ -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 }}

View File

@ -49,7 +49,7 @@
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@ -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() {

View File

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

View File

@ -2,7 +2,7 @@
{% load static %}
{% load i18n %}
{% block menubar %}}
{% block menubar %}
{% include 'part/navbar.html' with tab='sales-prices' %}
{% endblock %}

View File

@ -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'>

View File

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

View File

@ -11,7 +11,7 @@
{% block category_content %}
<div class='panel panel-default panel-inventree'>
<div class='panel-heading'>
<h4>{% trans "Subcategories" %}</h4>
</div>

View File

@ -62,7 +62,7 @@
});
$("#supplier-part-delete").click(function() {
var selections = $("#supplier-table").bootstrapTable("getSelections");
var parts = [];

View File

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

View File

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

View File

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

View File

@ -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()

View File

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

View File

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

View File

@ -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):

View File

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

View File

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

View File

@ -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 """