mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
1328 lines
42 KiB
Python
1328 lines
42 KiB
Python
"""
|
|
Django views for interacting with Part app
|
|
"""
|
|
|
|
# -*- coding: utf-8 -*-
|
|
from __future__ import unicode_literals
|
|
|
|
from django.core.files.base import ContentFile
|
|
from django.core.exceptions import ValidationError
|
|
from django.db import transaction
|
|
from django.db.utils import IntegrityError
|
|
from django.shortcuts import get_object_or_404
|
|
from django.shortcuts import HttpResponseRedirect
|
|
from django.utils.translation import gettext_lazy as _
|
|
from django.urls import reverse
|
|
from django.views.generic import DetailView, ListView
|
|
from django.forms import HiddenInput
|
|
from django.conf import settings
|
|
from django.contrib import messages
|
|
|
|
from moneyed import CURRENCIES
|
|
from djmoney.contrib.exchange.models import convert_money
|
|
from djmoney.contrib.exchange.exceptions import MissingRate
|
|
|
|
from PIL import Image
|
|
|
|
import requests
|
|
import os
|
|
import io
|
|
|
|
from decimal import Decimal
|
|
|
|
from .models import PartCategory, Part
|
|
from .models import PartParameterTemplate
|
|
from .models import PartCategoryParameterTemplate
|
|
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
|
|
|
from common.models import InvenTreeSetting
|
|
from company.models import SupplierPart
|
|
from common.files import FileManager
|
|
from common.views import FileManagementFormView, FileManagementAjaxView
|
|
|
|
from stock.models import StockItem, StockLocation
|
|
|
|
import common.settings as inventree_settings
|
|
|
|
from . import forms as part_forms
|
|
from . import settings as part_settings
|
|
from .bom import MakeBomTemplate, ExportBom, IsValidBOMFormat
|
|
from order.models import PurchaseOrderLineItem
|
|
|
|
from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
|
|
from InvenTree.views import QRCodeView
|
|
from InvenTree.views import InvenTreeRoleMixin
|
|
|
|
from InvenTree.helpers import str2bool
|
|
|
|
|
|
class PartIndex(InvenTreeRoleMixin, ListView):
|
|
""" View for displaying list of Part objects
|
|
"""
|
|
|
|
model = Part
|
|
template_name = 'part/category.html'
|
|
context_object_name = 'parts'
|
|
|
|
def get_queryset(self):
|
|
return Part.objects.all().select_related('category')
|
|
|
|
def get_context_data(self, **kwargs):
|
|
|
|
context = super(PartIndex, self).get_context_data(**kwargs).copy()
|
|
|
|
# View top-level categories
|
|
children = PartCategory.objects.filter(parent=None)
|
|
|
|
context['children'] = children
|
|
context['category_count'] = PartCategory.objects.count()
|
|
context['part_count'] = Part.objects.count()
|
|
|
|
return context
|
|
|
|
|
|
class PartSetCategory(AjaxUpdateView):
|
|
""" View for settings the part category for multiple parts at once """
|
|
|
|
ajax_template_name = 'part/set_category.html'
|
|
ajax_form_title = _('Set Part Category')
|
|
form_class = part_forms.SetPartCategoryForm
|
|
|
|
role_required = 'part.change'
|
|
|
|
category = None
|
|
parts = []
|
|
|
|
def get(self, request, *args, **kwargs):
|
|
""" Respond to a GET request to this view """
|
|
|
|
self.request = request
|
|
|
|
if 'parts[]' in request.GET:
|
|
self.parts = Part.objects.filter(id__in=request.GET.getlist('parts[]'))
|
|
else:
|
|
self.parts = []
|
|
|
|
return self.renderJsonResponse(request, form=self.get_form(), context=self.get_context_data())
|
|
|
|
def post(self, request, *args, **kwargs):
|
|
""" Respond to a POST request to this view """
|
|
|
|
self.parts = []
|
|
|
|
for item in request.POST:
|
|
if item.startswith('part_id_'):
|
|
pk = item.replace('part_id_', '')
|
|
|
|
try:
|
|
part = Part.objects.get(pk=pk)
|
|
except (Part.DoesNotExist, ValueError):
|
|
continue
|
|
|
|
self.parts.append(part)
|
|
|
|
self.category = None
|
|
|
|
if 'part_category' in request.POST:
|
|
pk = request.POST['part_category']
|
|
|
|
try:
|
|
self.category = PartCategory.objects.get(pk=pk)
|
|
except (PartCategory.DoesNotExist, ValueError):
|
|
self.category = None
|
|
|
|
valid = self.category is not None
|
|
|
|
data = {
|
|
'form_valid': valid,
|
|
'success': _('Set category for {n} parts').format(n=len(self.parts))
|
|
}
|
|
|
|
if valid:
|
|
self.set_category()
|
|
|
|
return self.renderJsonResponse(request, data=data, form=self.get_form(), context=self.get_context_data())
|
|
|
|
@transaction.atomic
|
|
def set_category(self):
|
|
for part in self.parts:
|
|
part.set_category(self.category)
|
|
|
|
def get_context_data(self):
|
|
""" Return context data for rendering in the form """
|
|
ctx = {}
|
|
|
|
ctx['parts'] = self.parts
|
|
ctx['categories'] = PartCategory.objects.all()
|
|
ctx['category'] = self.category
|
|
|
|
return ctx
|
|
|
|
|
|
class PartImport(FileManagementFormView):
|
|
''' Part: Upload file, match to fields and import parts(using multi-Step form) '''
|
|
permission_required = 'part.add'
|
|
|
|
class PartFileManager(FileManager):
|
|
REQUIRED_HEADERS = [
|
|
'Name',
|
|
'Description',
|
|
]
|
|
|
|
OPTIONAL_MATCH_HEADERS = [
|
|
'Category',
|
|
'default_location',
|
|
'default_supplier',
|
|
'variant_of',
|
|
]
|
|
|
|
OPTIONAL_HEADERS = [
|
|
'Keywords',
|
|
'IPN',
|
|
'Revision',
|
|
'Link',
|
|
'default_expiry',
|
|
'minimum_stock',
|
|
'Units',
|
|
'Notes',
|
|
'Active',
|
|
'base_cost',
|
|
'Multiple',
|
|
'Assembly',
|
|
'Component',
|
|
'is_template',
|
|
'Purchaseable',
|
|
'Salable',
|
|
'Trackable',
|
|
'Virtual',
|
|
'Stock',
|
|
]
|
|
|
|
name = 'part'
|
|
form_steps_template = [
|
|
'part/import_wizard/part_upload.html',
|
|
'part/import_wizard/match_fields.html',
|
|
'part/import_wizard/match_references.html',
|
|
]
|
|
form_steps_description = [
|
|
_("Upload File"),
|
|
_("Match Fields"),
|
|
_("Match References"),
|
|
]
|
|
|
|
form_field_map = {
|
|
'name': 'name',
|
|
'description': 'description',
|
|
'keywords': 'keywords',
|
|
'ipn': 'ipn',
|
|
'revision': 'revision',
|
|
'link': 'link',
|
|
'default_expiry': 'default_expiry',
|
|
'minimum_stock': 'minimum_stock',
|
|
'units': 'units',
|
|
'notes': 'notes',
|
|
'category': 'category',
|
|
'default_location': 'default_location',
|
|
'default_supplier': 'default_supplier',
|
|
'variant_of': 'variant_of',
|
|
'active': 'active',
|
|
'base_cost': 'base_cost',
|
|
'multiple': 'multiple',
|
|
'assembly': 'assembly',
|
|
'component': 'component',
|
|
'is_template': 'is_template',
|
|
'purchaseable': 'purchaseable',
|
|
'salable': 'salable',
|
|
'trackable': 'trackable',
|
|
'virtual': 'virtual',
|
|
'stock': 'stock',
|
|
}
|
|
file_manager_class = PartFileManager
|
|
|
|
def get_field_selection(self):
|
|
""" Fill the form fields for step 3 """
|
|
# fetch available elements
|
|
self.allowed_items = {}
|
|
self.matches = {}
|
|
|
|
self.allowed_items['Category'] = PartCategory.objects.all()
|
|
self.matches['Category'] = ['name__contains']
|
|
self.allowed_items['default_location'] = StockLocation.objects.all()
|
|
self.matches['default_location'] = ['name__contains']
|
|
self.allowed_items['default_supplier'] = SupplierPart.objects.all()
|
|
self.matches['default_supplier'] = ['SKU__contains']
|
|
self.allowed_items['variant_of'] = Part.objects.all()
|
|
self.matches['variant_of'] = ['name__contains']
|
|
|
|
# setup
|
|
self.file_manager.setup()
|
|
# collect submitted column indexes
|
|
col_ids = {}
|
|
for col in self.file_manager.HEADERS:
|
|
index = self.get_column_index(col)
|
|
if index >= 0:
|
|
col_ids[col] = index
|
|
|
|
# parse all rows
|
|
for row in self.rows:
|
|
# check each submitted column
|
|
for idx in col_ids:
|
|
data = row['data'][col_ids[idx]]['cell']
|
|
|
|
if idx in self.file_manager.OPTIONAL_MATCH_HEADERS:
|
|
try:
|
|
exact_match = self.allowed_items[idx].get(**{a: data for a in self.matches[idx]})
|
|
except (ValueError, self.allowed_items[idx].model.DoesNotExist, self.allowed_items[idx].model.MultipleObjectsReturned):
|
|
exact_match = None
|
|
|
|
row['match_options_' + idx] = self.allowed_items[idx]
|
|
row['match_' + idx] = exact_match
|
|
continue
|
|
|
|
# general fields
|
|
row[idx.lower()] = data
|
|
|
|
def done(self, form_list, **kwargs):
|
|
""" Create items """
|
|
items = self.get_clean_items()
|
|
|
|
import_done = 0
|
|
import_error = []
|
|
|
|
# Create Part instances
|
|
for part_data in items.values():
|
|
|
|
# set related parts
|
|
optional_matches = {}
|
|
for idx in self.file_manager.OPTIONAL_MATCH_HEADERS:
|
|
if idx.lower() in part_data:
|
|
try:
|
|
optional_matches[idx] = self.allowed_items[idx].get(pk=int(part_data[idx.lower()]))
|
|
except (ValueError, self.allowed_items[idx].model.DoesNotExist, self.allowed_items[idx].model.MultipleObjectsReturned):
|
|
optional_matches[idx] = None
|
|
else:
|
|
optional_matches[idx] = None
|
|
|
|
# add part
|
|
new_part = Part(
|
|
name=part_data.get('name', ''),
|
|
description=part_data.get('description', ''),
|
|
keywords=part_data.get('keywords', None),
|
|
IPN=part_data.get('ipn', None),
|
|
revision=part_data.get('revision', None),
|
|
link=part_data.get('link', None),
|
|
default_expiry=part_data.get('default_expiry', 0),
|
|
minimum_stock=part_data.get('minimum_stock', 0),
|
|
units=part_data.get('units', None),
|
|
notes=part_data.get('notes', None),
|
|
category=optional_matches['Category'],
|
|
default_location=optional_matches['default_location'],
|
|
default_supplier=optional_matches['default_supplier'],
|
|
variant_of=optional_matches['variant_of'],
|
|
active=str2bool(part_data.get('active', True)),
|
|
base_cost=part_data.get('base_cost', 0),
|
|
multiple=part_data.get('multiple', 1),
|
|
assembly=str2bool(part_data.get('assembly', part_settings.part_assembly_default())),
|
|
component=str2bool(part_data.get('component', part_settings.part_component_default())),
|
|
is_template=str2bool(part_data.get('is_template', part_settings.part_template_default())),
|
|
purchaseable=str2bool(part_data.get('purchaseable', part_settings.part_purchaseable_default())),
|
|
salable=str2bool(part_data.get('salable', part_settings.part_salable_default())),
|
|
trackable=str2bool(part_data.get('trackable', part_settings.part_trackable_default())),
|
|
virtual=str2bool(part_data.get('virtual', part_settings.part_virtual_default())),
|
|
)
|
|
try:
|
|
new_part.save()
|
|
|
|
# add stock item if set
|
|
if part_data.get('stock', None):
|
|
stock = StockItem(
|
|
part=new_part,
|
|
location=new_part.default_location,
|
|
quantity=int(part_data.get('stock', 1)),
|
|
)
|
|
stock.save()
|
|
import_done += 1
|
|
except ValidationError as _e:
|
|
import_error.append(', '.join(set(_e.messages)))
|
|
|
|
# Set alerts
|
|
if import_done:
|
|
alert = f"<strong>{_('Part-Import')}</strong><br>{_('Imported {n} parts').format(n=import_done)}"
|
|
messages.success(self.request, alert)
|
|
if import_error:
|
|
error_text = '\n'.join([f'<li><strong>x{import_error.count(a)}</strong>: {a}</li>' for a in set(import_error)])
|
|
messages.error(self.request, f"<strong>{_('Some errors occured:')}</strong><br><ul>{error_text}</ul>")
|
|
|
|
return HttpResponseRedirect(reverse('part-index'))
|
|
|
|
|
|
class PartImportAjax(FileManagementAjaxView, PartImport):
|
|
ajax_form_steps_template = [
|
|
'part/import_wizard/ajax_part_upload.html',
|
|
'part/import_wizard/ajax_match_fields.html',
|
|
'part/import_wizard/ajax_match_references.html',
|
|
]
|
|
|
|
def validate(self, obj, form, **kwargs):
|
|
return PartImport.validate(self, self.steps.current, form, **kwargs)
|
|
|
|
|
|
class PartDetail(InvenTreeRoleMixin, DetailView):
|
|
""" Detail view for Part object
|
|
"""
|
|
|
|
context_object_name = 'part'
|
|
queryset = Part.objects.all().select_related('category')
|
|
template_name = 'part/detail.html'
|
|
form_class = part_forms.PartPriceForm
|
|
|
|
# Add in some extra context information based on query params
|
|
def get_context_data(self, **kwargs):
|
|
"""
|
|
Provide extra context data to template
|
|
"""
|
|
context = super().get_context_data(**kwargs)
|
|
|
|
part = self.get_object()
|
|
|
|
ctx = part.get_context_data(self.request)
|
|
|
|
context.update(**ctx)
|
|
|
|
# Pricing information
|
|
if InvenTreeSetting.get_setting('PART_SHOW_PRICE_HISTORY', False):
|
|
ctx = self.get_pricing(self.get_quantity())
|
|
ctx['form'] = self.form_class(initial=self.get_initials())
|
|
|
|
context.update(ctx)
|
|
|
|
return context
|
|
|
|
def get_quantity(self):
|
|
""" Return set quantity in decimal format """
|
|
return Decimal(self.request.POST.get('quantity', 1))
|
|
|
|
def get_part(self):
|
|
return self.get_object()
|
|
|
|
def get_pricing(self, quantity=1, currency=None):
|
|
""" returns context with pricing information """
|
|
ctx = PartPricing.get_pricing(self, quantity, currency)
|
|
part = self.get_part()
|
|
default_currency = inventree_settings.currency_code_default()
|
|
|
|
# Stock history
|
|
if part.total_stock > 1:
|
|
price_history = []
|
|
stock = part.stock_entries(include_variants=False, in_stock=True).\
|
|
order_by('purchase_order__issue_date').prefetch_related('purchase_order', 'supplier_part')
|
|
|
|
for stock_item in stock:
|
|
if None in [stock_item.purchase_price, stock_item.quantity]:
|
|
continue
|
|
|
|
# convert purchase price to current currency - only one currency in the graph
|
|
try:
|
|
price = convert_money(stock_item.purchase_price, default_currency)
|
|
except MissingRate:
|
|
continue
|
|
|
|
line = {
|
|
'price': price.amount,
|
|
'qty': stock_item.quantity
|
|
}
|
|
# Supplier Part Name # TODO use in graph
|
|
if stock_item.supplier_part:
|
|
line['name'] = stock_item.supplier_part.pretty_name
|
|
|
|
if stock_item.supplier_part.unit_pricing and price:
|
|
line['price_diff'] = price.amount - stock_item.supplier_part.unit_pricing
|
|
line['price_part'] = stock_item.supplier_part.unit_pricing
|
|
|
|
# set date for graph labels
|
|
if stock_item.purchase_order and stock_item.purchase_order.issue_date:
|
|
line['date'] = stock_item.purchase_order.issue_date.isoformat()
|
|
elif stock_item.tracking_info.count() > 0:
|
|
line['date'] = stock_item.tracking_info.first().date.date().isoformat()
|
|
else:
|
|
# Not enough information
|
|
continue
|
|
|
|
price_history.append(line)
|
|
|
|
ctx['price_history'] = price_history
|
|
|
|
# BOM Information for Pie-Chart
|
|
if part.has_bom:
|
|
# get internal price setting
|
|
use_internal = InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False)
|
|
ctx_bom_parts = []
|
|
# iterate over all bom-items
|
|
for item in part.bom_items.all():
|
|
ctx_item = {'name': str(item.sub_part)}
|
|
price, qty = item.sub_part.get_price_range(quantity, internal=use_internal), item.quantity
|
|
|
|
price_min, price_max = 0, 0
|
|
if price: # check if price available
|
|
price_min = str((price[0] * qty) / quantity)
|
|
if len(set(price)) == 2: # min and max-price present
|
|
price_max = str((price[1] * qty) / quantity)
|
|
ctx['bom_pie_max'] = True # enable showing max prices in bom
|
|
|
|
ctx_item['max_price'] = price_min
|
|
ctx_item['min_price'] = price_max if price_max else price_min
|
|
ctx_bom_parts.append(ctx_item)
|
|
|
|
# add to global context
|
|
ctx['bom_parts'] = ctx_bom_parts
|
|
|
|
# Sale price history
|
|
sale_items = PurchaseOrderLineItem.objects.filter(part__part=part).order_by('order__issue_date').\
|
|
prefetch_related('order', ).all()
|
|
|
|
if sale_items:
|
|
sale_history = []
|
|
|
|
for sale_item in sale_items:
|
|
# check for not fully defined elements
|
|
if None in [sale_item.purchase_price, sale_item.quantity]:
|
|
continue
|
|
|
|
try:
|
|
price = convert_money(sale_item.purchase_price, default_currency)
|
|
except MissingRate:
|
|
continue
|
|
|
|
line = {
|
|
'price': price.amount if price else 0,
|
|
'qty': sale_item.quantity,
|
|
}
|
|
|
|
# set date for graph labels
|
|
if sale_item.order.issue_date:
|
|
line['date'] = sale_item.order.issue_date.isoformat()
|
|
elif sale_item.order.creation_date:
|
|
line['date'] = sale_item.order.creation_date.isoformat()
|
|
else:
|
|
line['date'] = _('None')
|
|
|
|
sale_history.append(line)
|
|
|
|
ctx['sale_history'] = sale_history
|
|
|
|
return ctx
|
|
|
|
def get_initials(self):
|
|
""" returns initials for form """
|
|
return {'quantity': self.get_quantity()}
|
|
|
|
def post(self, request, *args, **kwargs):
|
|
self.object = self.get_object()
|
|
kwargs['object'] = self.object
|
|
ctx = self.get_context_data(**kwargs)
|
|
return self.get(request, context=ctx)
|
|
|
|
|
|
class PartDetailFromIPN(PartDetail):
|
|
slug_field = 'IPN'
|
|
slug_url_kwarg = 'slug'
|
|
|
|
def get_object(self):
|
|
""" Return Part object which IPN field matches the slug value """
|
|
queryset = self.get_queryset()
|
|
# Get slug
|
|
slug = self.kwargs.get(self.slug_url_kwarg)
|
|
|
|
if slug is not None:
|
|
slug_field = self.get_slug_field()
|
|
# Filter by the slug value
|
|
queryset = queryset.filter(**{slug_field: slug})
|
|
|
|
try:
|
|
# Get unique part from queryset
|
|
part = queryset.get()
|
|
# Return Part object
|
|
return part
|
|
except queryset.model.MultipleObjectsReturned:
|
|
pass
|
|
except queryset.model.DoesNotExist:
|
|
pass
|
|
|
|
return None
|
|
|
|
def get(self, request, *args, **kwargs):
|
|
""" Attempt to match slug to a Part, else redirect to PartIndex view """
|
|
self.object = self.get_object()
|
|
|
|
if not self.object:
|
|
return HttpResponseRedirect(reverse('part-index'))
|
|
|
|
return super(PartDetailFromIPN, self).get(request, *args, **kwargs)
|
|
|
|
|
|
class PartQRCode(QRCodeView):
|
|
""" View for displaying a QR code for a Part object """
|
|
|
|
ajax_form_title = _("Part QR Code")
|
|
|
|
role_required = 'part.view'
|
|
|
|
def get_qr_data(self):
|
|
""" Generate QR code data for the Part """
|
|
|
|
try:
|
|
part = Part.objects.get(id=self.pk)
|
|
return part.format_barcode()
|
|
except Part.DoesNotExist:
|
|
return None
|
|
|
|
|
|
class PartImageDownloadFromURL(AjaxUpdateView):
|
|
"""
|
|
View for downloading an image from a provided URL
|
|
"""
|
|
|
|
model = Part
|
|
|
|
ajax_template_name = 'image_download.html'
|
|
form_class = part_forms.PartImageDownloadForm
|
|
ajax_form_title = _('Download Image')
|
|
|
|
def validate(self, part, form):
|
|
"""
|
|
Validate that the image data are correct.
|
|
|
|
- Try to download the image!
|
|
"""
|
|
|
|
# First ensure that the normal validation routines pass
|
|
if not form.is_valid():
|
|
return
|
|
|
|
# We can now extract a valid URL from the form data
|
|
url = form.cleaned_data.get('url', None)
|
|
|
|
# Download the file
|
|
response = requests.get(url, stream=True)
|
|
|
|
# Look at response header, reject if too large
|
|
content_length = response.headers.get('Content-Length', '0')
|
|
|
|
try:
|
|
content_length = int(content_length)
|
|
except (ValueError):
|
|
# If we cannot extract meaningful length, just assume it's "small enough"
|
|
content_length = 0
|
|
|
|
# TODO: Factor this out into a configurable setting
|
|
MAX_IMG_LENGTH = 10 * 1024 * 1024
|
|
|
|
if content_length > MAX_IMG_LENGTH:
|
|
form.add_error('url', _('Image size exceeds maximum allowable size for download'))
|
|
return
|
|
|
|
self.response = response
|
|
|
|
# Check for valid response code
|
|
if not response.status_code == 200:
|
|
form.add_error('url', _('Invalid response: {code}').format(code=response.status_code))
|
|
return
|
|
|
|
response.raw.decode_content = True
|
|
|
|
try:
|
|
self.image = Image.open(response.raw).convert()
|
|
self.image.verify()
|
|
except:
|
|
form.add_error('url', _("Supplied URL is not a valid image file"))
|
|
return
|
|
|
|
def save(self, part, form, **kwargs):
|
|
"""
|
|
Save the downloaded image to the part
|
|
"""
|
|
|
|
fmt = self.image.format
|
|
|
|
if not fmt:
|
|
fmt = 'PNG'
|
|
|
|
buffer = io.BytesIO()
|
|
|
|
self.image.save(buffer, format=fmt)
|
|
|
|
# Construct a simplified name for the image
|
|
filename = f"part_{part.pk}_image.{fmt.lower()}"
|
|
|
|
part.image.save(
|
|
filename,
|
|
ContentFile(buffer.getvalue()),
|
|
)
|
|
|
|
|
|
class PartImageSelect(AjaxUpdateView):
|
|
""" View for selecting Part image from existing images. """
|
|
|
|
model = Part
|
|
ajax_template_name = 'part/select_image.html'
|
|
ajax_form_title = _('Select Part Image')
|
|
|
|
fields = [
|
|
'image',
|
|
]
|
|
|
|
def post(self, request, *args, **kwargs):
|
|
|
|
part = self.get_object()
|
|
form = self.get_form()
|
|
|
|
img = request.POST.get('image', '')
|
|
|
|
img = os.path.basename(img)
|
|
|
|
data = {}
|
|
|
|
if img:
|
|
img_path = os.path.join(settings.MEDIA_ROOT, 'part_images', img)
|
|
|
|
# Ensure that the image already exists
|
|
if os.path.exists(img_path):
|
|
|
|
part.image = os.path.join('part_images', img)
|
|
part.save()
|
|
|
|
data['success'] = _('Updated part image')
|
|
|
|
if 'success' not in data:
|
|
data['error'] = _('Part image not found')
|
|
|
|
return self.renderJsonResponse(request, form, data)
|
|
|
|
|
|
class BomUpload(InvenTreeRoleMixin, DetailView):
|
|
""" View for uploading a BOM file, and handling BOM data importing. """
|
|
|
|
context_object_name = 'part'
|
|
queryset = Part.objects.all()
|
|
template_name = 'part/upload_bom.html'
|
|
|
|
|
|
class BomUploadTemplate(AjaxView):
|
|
"""
|
|
Provide a BOM upload template file for download.
|
|
- Generates a template file in the provided format e.g. ?format=csv
|
|
"""
|
|
|
|
def get(self, request, *args, **kwargs):
|
|
|
|
export_format = request.GET.get('format', 'csv')
|
|
|
|
return MakeBomTemplate(export_format)
|
|
|
|
|
|
class BomDownload(AjaxView):
|
|
"""
|
|
Provide raw download of a BOM file.
|
|
- File format should be passed as a query param e.g. ?format=csv
|
|
"""
|
|
|
|
role_required = 'part.view'
|
|
|
|
model = Part
|
|
|
|
def get(self, request, *args, **kwargs):
|
|
|
|
part = get_object_or_404(Part, pk=self.kwargs['pk'])
|
|
|
|
export_format = request.GET.get('format', 'csv')
|
|
|
|
cascade = str2bool(request.GET.get('cascade', False))
|
|
|
|
parameter_data = str2bool(request.GET.get('parameter_data', False))
|
|
|
|
stock_data = str2bool(request.GET.get('stock_data', False))
|
|
|
|
supplier_data = str2bool(request.GET.get('supplier_data', False))
|
|
|
|
manufacturer_data = str2bool(request.GET.get('manufacturer_data', False))
|
|
|
|
levels = request.GET.get('levels', None)
|
|
|
|
if levels is not None:
|
|
try:
|
|
levels = int(levels)
|
|
|
|
if levels <= 0:
|
|
levels = None
|
|
|
|
except ValueError:
|
|
levels = None
|
|
|
|
if not IsValidBOMFormat(export_format):
|
|
export_format = 'csv'
|
|
|
|
return ExportBom(part,
|
|
fmt=export_format,
|
|
cascade=cascade,
|
|
max_levels=levels,
|
|
parameter_data=parameter_data,
|
|
stock_data=stock_data,
|
|
supplier_data=supplier_data,
|
|
manufacturer_data=manufacturer_data,
|
|
)
|
|
|
|
def get_data(self):
|
|
return {
|
|
'info': 'Exported BOM'
|
|
}
|
|
|
|
|
|
class PartDelete(AjaxDeleteView):
|
|
""" View to delete a Part object """
|
|
|
|
model = Part
|
|
ajax_template_name = 'part/partial_delete.html'
|
|
ajax_form_title = _('Confirm Part Deletion')
|
|
context_object_name = 'part'
|
|
|
|
success_url = '/part/'
|
|
|
|
def get_data(self):
|
|
return {
|
|
'danger': _('Part was deleted'),
|
|
}
|
|
|
|
|
|
class PartPricing(AjaxView):
|
|
""" View for inspecting part pricing information """
|
|
|
|
model = Part
|
|
ajax_template_name = "part/part_pricing.html"
|
|
ajax_form_title = _("Part Pricing")
|
|
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):
|
|
try:
|
|
return Part.objects.get(id=self.kwargs['pk'])
|
|
except Part.DoesNotExist:
|
|
return None
|
|
|
|
def get_pricing(self, quantity=1, currency=None):
|
|
""" returns context with pricing information """
|
|
if quantity <= 0:
|
|
quantity = 1
|
|
|
|
# TODO - Capacity for price comparison in different currencies
|
|
currency = None
|
|
|
|
# Currency scaler
|
|
scaler = Decimal(1.0)
|
|
|
|
part = self.get_part()
|
|
|
|
ctx = {
|
|
'part': part,
|
|
'quantity': quantity,
|
|
'currency': currency,
|
|
}
|
|
|
|
if part is None:
|
|
return ctx
|
|
|
|
# Supplier pricing information
|
|
if part.supplier_count > 0:
|
|
buy_price = part.get_supplier_price_range(quantity)
|
|
|
|
if buy_price is not None:
|
|
min_buy_price, max_buy_price = buy_price
|
|
|
|
min_buy_price /= scaler
|
|
max_buy_price /= scaler
|
|
|
|
min_unit_buy_price = round(min_buy_price / quantity, 3)
|
|
max_unit_buy_price = round(max_buy_price / quantity, 3)
|
|
|
|
min_buy_price = round(min_buy_price, 3)
|
|
max_buy_price = round(max_buy_price, 3)
|
|
|
|
if min_buy_price:
|
|
ctx['min_total_buy_price'] = min_buy_price
|
|
ctx['min_unit_buy_price'] = min_unit_buy_price
|
|
|
|
if max_buy_price:
|
|
ctx['max_total_buy_price'] = max_buy_price
|
|
ctx['max_unit_buy_price'] = max_unit_buy_price
|
|
|
|
# BOM pricing information
|
|
if part.bom_count > 0:
|
|
|
|
use_internal = InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False)
|
|
bom_price = part.get_bom_price_range(quantity, internal=use_internal)
|
|
purchase_price = part.get_bom_price_range(quantity, purchase=True)
|
|
|
|
if bom_price is not None:
|
|
min_bom_price, max_bom_price = bom_price
|
|
|
|
min_bom_price /= scaler
|
|
max_bom_price /= scaler
|
|
|
|
if min_bom_price:
|
|
ctx['min_total_bom_price'] = round(min_bom_price, 3)
|
|
ctx['min_unit_bom_price'] = round(min_bom_price / quantity, 3)
|
|
|
|
if max_bom_price:
|
|
ctx['max_total_bom_price'] = round(max_bom_price, 3)
|
|
ctx['max_unit_bom_price'] = round(max_bom_price / quantity, 3)
|
|
|
|
if purchase_price is not None:
|
|
min_bom_purchase_price, max_bom_purchase_price = purchase_price
|
|
|
|
min_bom_purchase_price /= scaler
|
|
max_bom_purchase_price /= scaler
|
|
if min_bom_purchase_price:
|
|
ctx['min_total_bom_purchase_price'] = round(min_bom_purchase_price, 3)
|
|
ctx['min_unit_bom_purchase_price'] = round(min_bom_purchase_price / quantity, 3)
|
|
|
|
if max_bom_purchase_price:
|
|
ctx['max_total_bom_purchase_price'] = round(max_bom_purchase_price, 3)
|
|
ctx['max_unit_bom_purchase_price'] = round(max_bom_purchase_price / quantity, 3)
|
|
|
|
# internal part pricing information
|
|
internal_part_price = part.get_internal_price(quantity)
|
|
if internal_part_price is not None:
|
|
ctx['total_internal_part_price'] = round(internal_part_price, 3)
|
|
ctx['unit_internal_part_price'] = round(internal_part_price / quantity, 3)
|
|
|
|
# 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_initials(self):
|
|
""" returns initials for form """
|
|
return {'quantity': self.get_quantity()}
|
|
|
|
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):
|
|
|
|
currency = None
|
|
|
|
quantity = self.get_quantity()
|
|
|
|
# Retain quantity value set by user
|
|
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
|
|
|
|
return self.renderJsonResponse(request, form, data=data, context=self.get_pricing(quantity, currency))
|
|
|
|
|
|
class PartParameterTemplateCreate(AjaxCreateView):
|
|
"""
|
|
View for creating a new PartParameterTemplate
|
|
"""
|
|
|
|
model = PartParameterTemplate
|
|
form_class = part_forms.EditPartParameterTemplateForm
|
|
ajax_form_title = _('Create Part Parameter Template')
|
|
|
|
|
|
class PartParameterTemplateEdit(AjaxUpdateView):
|
|
"""
|
|
View for editing a PartParameterTemplate
|
|
"""
|
|
|
|
model = PartParameterTemplate
|
|
form_class = part_forms.EditPartParameterTemplateForm
|
|
ajax_form_title = _('Edit Part Parameter Template')
|
|
|
|
|
|
class PartParameterTemplateDelete(AjaxDeleteView):
|
|
""" View for deleting an existing PartParameterTemplate """
|
|
|
|
model = PartParameterTemplate
|
|
ajax_form_title = _("Delete Part Parameter Template")
|
|
|
|
|
|
class CategoryDetail(InvenTreeRoleMixin, DetailView):
|
|
""" Detail view for PartCategory """
|
|
|
|
model = PartCategory
|
|
context_object_name = 'category'
|
|
queryset = PartCategory.objects.all().prefetch_related('children')
|
|
template_name = 'part/category.html'
|
|
|
|
def get_context_data(self, **kwargs):
|
|
|
|
context = super(CategoryDetail, self).get_context_data(**kwargs).copy()
|
|
|
|
try:
|
|
context['part_count'] = kwargs['object'].partcount()
|
|
except KeyError:
|
|
context['part_count'] = 0
|
|
|
|
# Get current category
|
|
category = kwargs.get('object', None)
|
|
|
|
if category:
|
|
|
|
# Insert "starred" information
|
|
context['starred'] = category.is_starred_by(self.request.user)
|
|
context['starred_directly'] = context['starred'] and category.is_starred_by(
|
|
self.request.user,
|
|
include_parents=False,
|
|
)
|
|
|
|
return context
|
|
|
|
|
|
class CategoryEdit(AjaxUpdateView):
|
|
"""
|
|
Update view to edit a PartCategory
|
|
"""
|
|
|
|
model = PartCategory
|
|
form_class = part_forms.EditCategoryForm
|
|
ajax_template_name = 'modal_form.html'
|
|
ajax_form_title = _('Edit Part Category')
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super(CategoryEdit, self).get_context_data(**kwargs).copy()
|
|
|
|
try:
|
|
context['category'] = self.get_object()
|
|
except:
|
|
pass
|
|
|
|
return context
|
|
|
|
def get_form(self):
|
|
""" Customize form data for PartCategory editing.
|
|
|
|
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
|
|
parent_choices = PartCategory.objects.all()
|
|
parent_choices = parent_choices.exclude(id__in=category.getUniqueChildren())
|
|
|
|
form.fields['parent'].queryset = parent_choices
|
|
|
|
return form
|
|
|
|
|
|
class CategoryDelete(AjaxDeleteView):
|
|
"""
|
|
Delete view to delete a PartCategory
|
|
"""
|
|
|
|
model = PartCategory
|
|
ajax_template_name = 'part/category_delete.html'
|
|
ajax_form_title = _('Delete Part Category')
|
|
context_object_name = 'category'
|
|
success_url = '/part/'
|
|
|
|
def get_data(self):
|
|
return {
|
|
'danger': _('Part category was deleted'),
|
|
}
|
|
|
|
|
|
class CategoryParameterTemplateCreate(AjaxCreateView):
|
|
""" View for creating a new PartCategoryParameterTemplate """
|
|
|
|
model = PartCategoryParameterTemplate
|
|
form_class = part_forms.EditCategoryParameterTemplateForm
|
|
ajax_form_title = _('Create Category Parameter Template')
|
|
|
|
def get_initial(self):
|
|
""" Get initial data for Category """
|
|
initials = super().get_initial()
|
|
|
|
category_id = self.kwargs.get('pk', None)
|
|
|
|
if category_id:
|
|
try:
|
|
initials['category'] = PartCategory.objects.get(pk=category_id)
|
|
except (PartCategory.DoesNotExist, ValueError):
|
|
pass
|
|
|
|
return initials
|
|
|
|
def get_form(self):
|
|
""" Create a form to upload a new CategoryParameterTemplate
|
|
- Hide the 'category' field (parent part)
|
|
- Display parameter templates which are not yet related
|
|
"""
|
|
|
|
form = super(AjaxCreateView, self).get_form()
|
|
|
|
form.fields['category'].widget = HiddenInput()
|
|
|
|
if form.is_valid():
|
|
form.cleaned_data['category'] = self.kwargs.get('pk', None)
|
|
|
|
try:
|
|
# Get selected category
|
|
category = self.get_initial()['category']
|
|
|
|
# Get existing parameter templates
|
|
parameters = [template.parameter_template.pk
|
|
for template in category.get_parameter_templates()]
|
|
|
|
# Exclude templates already linked to category
|
|
updated_choices = []
|
|
for choice in form.fields["parameter_template"].choices:
|
|
if (choice[0] not in parameters):
|
|
updated_choices.append(choice)
|
|
|
|
# Update choices for parameter templates
|
|
form.fields['parameter_template'].choices = updated_choices
|
|
except KeyError:
|
|
pass
|
|
|
|
return form
|
|
|
|
def post(self, request, *args, **kwargs):
|
|
""" Capture the POST request
|
|
|
|
- If the add_to_all_categories object is set, link parameter template to
|
|
all categories
|
|
- If the add_to_same_level_categories object is set, link parameter template to
|
|
same level categories
|
|
"""
|
|
|
|
form = self.get_form()
|
|
|
|
valid = form.is_valid()
|
|
|
|
if valid:
|
|
add_to_same_level_categories = form.cleaned_data['add_to_same_level_categories']
|
|
add_to_all_categories = form.cleaned_data['add_to_all_categories']
|
|
|
|
selected_category = PartCategory.objects.get(pk=int(self.kwargs['pk']))
|
|
parameter_template = form.cleaned_data['parameter_template']
|
|
default_value = form.cleaned_data['default_value']
|
|
|
|
categories = PartCategory.objects.all()
|
|
|
|
if add_to_same_level_categories and not add_to_all_categories:
|
|
# Get level
|
|
level = selected_category.level
|
|
# Filter same level categories
|
|
categories = categories.filter(level=level)
|
|
|
|
if add_to_same_level_categories or add_to_all_categories:
|
|
# Add parameter template and default value to categories
|
|
for category in categories:
|
|
# Skip selected category (will be processed in the post call)
|
|
if category.pk != selected_category.pk:
|
|
try:
|
|
cat_template = PartCategoryParameterTemplate.objects.create(category=category,
|
|
parameter_template=parameter_template,
|
|
default_value=default_value)
|
|
cat_template.save()
|
|
except IntegrityError:
|
|
# Parameter template is already linked to category
|
|
pass
|
|
|
|
return super().post(request, *args, **kwargs)
|
|
|
|
|
|
class CategoryParameterTemplateEdit(AjaxUpdateView):
|
|
""" View for editing a PartCategoryParameterTemplate """
|
|
|
|
model = PartCategoryParameterTemplate
|
|
form_class = part_forms.EditCategoryParameterTemplateForm
|
|
ajax_form_title = _('Edit Category Parameter Template')
|
|
|
|
def get_object(self):
|
|
try:
|
|
self.object = self.model.objects.get(pk=self.kwargs['pid'])
|
|
except:
|
|
return None
|
|
|
|
return self.object
|
|
|
|
def get_form(self):
|
|
""" Create a form to upload a new CategoryParameterTemplate
|
|
- Hide the 'category' field (parent part)
|
|
- Display parameter templates which are not yet related
|
|
"""
|
|
|
|
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()
|
|
|
|
if form.is_valid():
|
|
form.cleaned_data['category'] = self.kwargs.get('pk', None)
|
|
|
|
try:
|
|
# Get selected category
|
|
category = PartCategory.objects.get(pk=self.kwargs.get('pk', None))
|
|
# Get selected template
|
|
selected_template = self.get_object().parameter_template
|
|
|
|
# Get existing parameter templates
|
|
parameters = [template.parameter_template.pk
|
|
for template in category.get_parameter_templates()
|
|
if template.parameter_template.pk != selected_template.pk]
|
|
|
|
# Exclude templates already linked to category
|
|
updated_choices = []
|
|
for choice in form.fields["parameter_template"].choices:
|
|
if (choice[0] not in parameters):
|
|
updated_choices.append(choice)
|
|
|
|
# Update choices for parameter templates
|
|
form.fields['parameter_template'].choices = updated_choices
|
|
# Set initial choice to current template
|
|
form.fields['parameter_template'].initial = selected_template
|
|
except KeyError:
|
|
pass
|
|
|
|
return form
|
|
|
|
|
|
class CategoryParameterTemplateDelete(AjaxDeleteView):
|
|
""" View for deleting an existing PartCategoryParameterTemplate """
|
|
|
|
model = PartCategoryParameterTemplate
|
|
ajax_form_title = _("Delete Category Parameter Template")
|
|
|
|
def get_object(self):
|
|
try:
|
|
self.object = self.model.objects.get(pk=self.kwargs['pid'])
|
|
except:
|
|
return None
|
|
|
|
return self.object
|
|
|
|
|
|
class PartSalePriceBreakCreate(AjaxCreateView):
|
|
"""
|
|
View for creating a sale price break for a part
|
|
"""
|
|
|
|
model = PartSellPriceBreak
|
|
form_class = part_forms.EditPartSalePriceBreakForm
|
|
ajax_form_title = _('Add Price Break')
|
|
|
|
def get_data(self):
|
|
return {
|
|
'success': _('Added new price break')
|
|
}
|
|
|
|
def get_part(self):
|
|
try:
|
|
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'))
|
|
except (ValueError, Part.DoesNotExist):
|
|
part = None
|
|
|
|
return part
|
|
|
|
def get_form(self):
|
|
|
|
form = super(AjaxCreateView, self).get_form()
|
|
form.fields['part'].widget = HiddenInput()
|
|
|
|
return form
|
|
|
|
def get_initial(self):
|
|
|
|
initials = super(AjaxCreateView, self).get_initial()
|
|
|
|
initials['part'] = self.get_part()
|
|
|
|
default_currency = inventree_settings.currency_code_default()
|
|
currency = CURRENCIES.get(default_currency, None)
|
|
|
|
if currency is not None:
|
|
initials['price'] = [1.0, currency]
|
|
|
|
return initials
|
|
|
|
|
|
class PartSalePriceBreakEdit(AjaxUpdateView):
|
|
""" View for editing a sale price break """
|
|
|
|
model = PartSellPriceBreak
|
|
form_class = part_forms.EditPartSalePriceBreakForm
|
|
ajax_form_title = _('Edit Price Break')
|
|
|
|
def get_form(self):
|
|
|
|
form = super().get_form()
|
|
form.fields['part'].widget = HiddenInput()
|
|
|
|
return form
|
|
|
|
|
|
class PartSalePriceBreakDelete(AjaxDeleteView):
|
|
""" View for deleting a sale price break """
|
|
|
|
model = PartSellPriceBreak
|
|
ajax_form_title = _("Delete Price Break")
|
|
ajax_template_name = "modal_delete_form.html"
|
|
|
|
|
|
class PartInternalPriceBreakCreate(PartSalePriceBreakCreate):
|
|
""" View for creating a internal price break for a part """
|
|
|
|
model = PartInternalPriceBreak
|
|
form_class = part_forms.EditPartInternalPriceBreakForm
|
|
ajax_form_title = _('Add Internal Price Break')
|
|
permission_required = 'roles.sales_order.add'
|
|
|
|
|
|
class PartInternalPriceBreakEdit(PartSalePriceBreakEdit):
|
|
""" View for editing a internal price break """
|
|
|
|
model = PartInternalPriceBreak
|
|
form_class = part_forms.EditPartInternalPriceBreakForm
|
|
ajax_form_title = _('Edit Internal Price Break')
|
|
permission_required = 'roles.sales_order.change'
|
|
|
|
|
|
class PartInternalPriceBreakDelete(PartSalePriceBreakDelete):
|
|
""" View for deleting a internal price break """
|
|
|
|
model = PartInternalPriceBreak
|
|
ajax_form_title = _("Delete Internal Price Break")
|
|
permission_required = 'roles.sales_order.delete'
|