Merge branch 'inventree:master' into price-history

This commit is contained in:
Matthias Mair 2021-05-12 23:39:57 +02:00
commit b1410c7c2b
39 changed files with 2363 additions and 206 deletions

View File

@ -30,6 +30,7 @@ jobs:
context: ./docker context: ./docker
platforms: linux/amd64,linux/arm64,linux/arm/v7 platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true push: true
target: production
repository: inventree/inventree repository: inventree/inventree
tags: inventree/inventree:latest tags: inventree/inventree:latest
- name: Image Digest - name: Image Digest

View File

@ -28,4 +28,5 @@ jobs:
repository: inventree/inventree repository: inventree/inventree
tag_with_ref: true tag_with_ref: true
dockerfile: ./Dockerfile dockerfile: ./Dockerfile
target: production
platforms: linux/amd64,linux/arm64,linux/arm/v7 platforms: linux/amd64,linux/arm64,linux/arm/v7

1
.gitignore vendored
View File

@ -31,6 +31,7 @@ var/
*.log *.log
local_settings.py local_settings.py
*.sqlite3 *.sqlite3
*.sqlite3-journal
*.backup *.backup
*.old *.old

View File

@ -6,6 +6,7 @@ Provides extra global data to all templates.
from InvenTree.status_codes import SalesOrderStatus, PurchaseOrderStatus from InvenTree.status_codes import SalesOrderStatus, PurchaseOrderStatus
from InvenTree.status_codes import BuildStatus, StockStatus from InvenTree.status_codes import BuildStatus, StockStatus
from InvenTree.status_codes import StockHistoryCode
import InvenTree.status import InvenTree.status
@ -65,6 +66,7 @@ def status_codes(request):
'PurchaseOrderStatus': PurchaseOrderStatus, 'PurchaseOrderStatus': PurchaseOrderStatus,
'BuildStatus': BuildStatus, 'BuildStatus': BuildStatus,
'StockStatus': StockStatus, 'StockStatus': StockStatus,
'StockHistoryCode': StockHistoryCode,
} }

View File

@ -263,6 +263,7 @@ INSTALLED_APPS = [
'djmoney.contrib.exchange', # django-money exchange rates 'djmoney.contrib.exchange', # django-money exchange rates
'error_report', # Error reporting in the admin interface 'error_report', # Error reporting in the admin interface
'django_q', 'django_q',
'formtools', # Form wizard tools
] ]
MIDDLEWARE = CONFIG.get('middleware', [ MIDDLEWARE = CONFIG.get('middleware', [
@ -430,11 +431,15 @@ It can be specified in config.yaml (or envvar) as either (for example):
- django.db.backends.postgresql - django.db.backends.postgresql
""" """
db_engine = db_config['ENGINE'] db_engine = db_config['ENGINE'].lower()
if db_engine.lower() in ['sqlite3', 'postgresql', 'mysql']: # Correct common misspelling
if db_engine == 'sqlite':
db_engine = 'sqlite3'
if db_engine in ['sqlite3', 'postgresql', 'mysql']:
# Prepend the required python module string # Prepend the required python module string
db_engine = f'django.db.backends.{db_engine.lower()}' db_engine = f'django.db.backends.{db_engine}'
db_config['ENGINE'] = db_engine db_config['ENGINE'] = db_engine
db_name = db_config['NAME'] db_name = db_config['NAME']

View File

@ -7,6 +7,8 @@ class StatusCode:
This is used to map a set of integer values to text. This is used to map a set of integer values to text.
""" """
colors = {}
@classmethod @classmethod
def render(cls, key, large=False): def render(cls, key, large=False):
""" """
@ -224,6 +226,82 @@ class StockStatus(StatusCode):
] ]
class StockHistoryCode(StatusCode):
LEGACY = 0
CREATED = 1
# Manual editing operations
EDITED = 5
ASSIGNED_SERIAL = 6
# Manual stock operations
STOCK_COUNT = 10
STOCK_ADD = 11
STOCK_REMOVE = 12
# Location operations
STOCK_MOVE = 20
# Installation operations
INSTALLED_INTO_ASSEMBLY = 30
REMOVED_FROM_ASSEMBLY = 31
INSTALLED_CHILD_ITEM = 35
REMOVED_CHILD_ITEM = 36
# Stock splitting operations
SPLIT_FROM_PARENT = 40
SPLIT_CHILD_ITEM = 42
# Build order codes
BUILD_OUTPUT_CREATED = 50
BUILD_OUTPUT_COMPLETED = 55
# Sales order codes
# Purchase order codes
RECEIVED_AGAINST_PURCHASE_ORDER = 70
# Customer actions
SENT_TO_CUSTOMER = 100
RETURNED_FROM_CUSTOMER = 105
options = {
LEGACY: _('Legacy stock tracking entry'),
CREATED: _('Stock item created'),
EDITED: _('Edited stock item'),
ASSIGNED_SERIAL: _('Assigned serial number'),
STOCK_COUNT: _('Stock counted'),
STOCK_ADD: _('Stock manually added'),
STOCK_REMOVE: _('Stock manually removed'),
STOCK_MOVE: _('Location changed'),
INSTALLED_INTO_ASSEMBLY: _('Installed into assembly'),
REMOVED_FROM_ASSEMBLY: _('Removed from assembly'),
INSTALLED_CHILD_ITEM: _('Installed component item'),
REMOVED_CHILD_ITEM: _('Removed component item'),
SPLIT_FROM_PARENT: _('Split from parent item'),
SPLIT_CHILD_ITEM: _('Split child item'),
SENT_TO_CUSTOMER: _('Sent to customer'),
RETURNED_FROM_CUSTOMER: _('Returned from customer'),
BUILD_OUTPUT_CREATED: _('Build order output created'),
BUILD_OUTPUT_COMPLETED: _('Build order output completed'),
RECEIVED_AGAINST_PURCHASE_ORDER: _('Received against purchase order')
}
class BuildStatus(StatusCode): class BuildStatus(StatusCode):
# Build status codes # Build status codes

View File

@ -22,7 +22,7 @@ from markdownx.models import MarkdownxField
from mptt.models import MPTTModel, TreeForeignKey from mptt.models import MPTTModel, TreeForeignKey
from InvenTree.status_codes import BuildStatus, StockStatus from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode
from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode
from InvenTree.validators import validate_build_order_reference from InvenTree.validators import validate_build_order_reference
from InvenTree.models import InvenTreeAttachment from InvenTree.models import InvenTreeAttachment
@ -811,6 +811,7 @@ class Build(MPTTModel):
# Select the location for the build output # Select the location for the build output
location = kwargs.get('location', self.destination) location = kwargs.get('location', self.destination)
status = kwargs.get('status', StockStatus.OK) status = kwargs.get('status', StockStatus.OK)
notes = kwargs.get('notes', '')
# List the allocated BuildItem objects for the given output # List the allocated BuildItem objects for the given output
allocated_items = output.items_to_install.all() allocated_items = output.items_to_install.all()
@ -834,10 +835,13 @@ class Build(MPTTModel):
output.save() output.save()
output.addTransactionNote( output.add_tracking_entry(
_('Completed build output'), StockHistoryCode.BUILD_OUTPUT_COMPLETED,
user, user,
system=True notes=notes,
deltas={
'status': status,
}
) )
# Increase the completed quantity for this build # Increase the completed quantity for this build

240
InvenTree/common/files.py Normal file
View File

@ -0,0 +1,240 @@
"""
Files management tools.
"""
from rapidfuzz import fuzz
import tablib
import os
from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError
# from company.models import ManufacturerPart, SupplierPart
class FileManager:
""" Class for managing an uploaded file """
name = ''
# Fields which are absolutely necessary for valid upload
REQUIRED_HEADERS = []
# Fields which are used for item matching (only one of them is needed)
ITEM_MATCH_HEADERS = []
# Fields which would be helpful but are not required
OPTIONAL_HEADERS = []
EDITABLE_HEADERS = []
HEADERS = []
def __init__(self, file, name=None):
""" Initialize the FileManager class with a user-uploaded file object """
# Set name
if name:
self.name = name
# Process initial file
self.process(file)
# Update headers
self.update_headers()
@classmethod
def validate(cls, file):
""" Validate file extension and data """
cleaned_data = None
ext = os.path.splitext(file.name)[-1].lower().replace('.', '')
if ext in ['csv', 'tsv', ]:
# These file formats need string decoding
raw_data = file.read().decode('utf-8')
# Reset stream position to beginning of file
file.seek(0)
elif ext in ['xls', 'xlsx', 'json', 'yaml', ]:
raw_data = file.read()
# Reset stream position to beginning of file
file.seek(0)
else:
raise ValidationError(_(f'Unsupported file format: {ext.upper()}'))
try:
cleaned_data = tablib.Dataset().load(raw_data, format=ext)
except tablib.UnsupportedFormat:
raise ValidationError(_('Error reading file (invalid format)'))
except tablib.core.InvalidDimensions:
raise ValidationError(_('Error reading file (incorrect dimension)'))
except KeyError:
raise ValidationError(_('Error reading file (data could be corrupted)'))
return cleaned_data
def process(self, file):
""" Process file """
self.data = self.__class__.validate(file)
def update_headers(self):
""" Update headers """
self.HEADERS = self.REQUIRED_HEADERS + self.ITEM_MATCH_HEADERS + self.OPTIONAL_HEADERS
def setup(self):
""" Setup headers depending on the file name """
if not self.name:
return
if self.name == 'order':
self.REQUIRED_HEADERS = [
'Quantity',
]
self.ITEM_MATCH_HEADERS = [
'Manufacturer_MPN',
'Supplier_SKU',
]
self.OPTIONAL_HEADERS = [
'Purchase_Price',
'Reference',
'Notes',
]
# Update headers
self.update_headers()
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
"""
# Try for an exact match
for h in self.HEADERS:
if h == header:
return h
# Try for a case-insensitive match
for h in self.HEADERS:
if h.lower() == header.lower():
return h
# Try for a case-insensitive match with space replacement
for h in self.HEADERS:
if h.lower() == header.lower().replace(' ', '_'):
return h
# Finally, look for a close match using fuzzy matching
matches = []
for h in self.HEADERS:
ratio = fuzz.partial_ratio(header, h)
if ratio > threshold:
matches.append({'header': h, 'match': ratio})
if len(matches) > 0:
matches = sorted(matches, key=lambda item: item['match'], reverse=True)
return matches[0]['header']
return None
def columns(self):
""" Return a list of headers for the thingy """
headers = []
for header in self.data.headers:
# Guess header
guess = self.guess_header(header, threshold=95)
# Check if already present
guess_exists = False
for idx, data in enumerate(headers):
if guess == data['guess']:
guess_exists = True
break
if not guess_exists:
headers.append({
'name': header,
'guess': guess
})
else:
headers.append({
'name': header,
'guess': None
})
return headers
def col_count(self):
if self.data is None:
return 0
return len(self.data.headers)
def row_count(self):
""" Return the number of rows in the file. """
if self.data is None:
return 0
return len(self.data)
def rows(self):
""" Return a list of all rows """
rows = []
for i in range(self.row_count()):
data = [item for item in self.get_row_data(i)]
# Is the row completely empty? Skip!
empty = True
for idx, item in enumerate(data):
if len(str(item).strip()) > 0:
empty = False
try:
# Excel import casts number-looking-items into floats, which is annoying
if item == int(item) and not str(item) == str(int(item)):
data[idx] = int(item)
except ValueError:
pass
except TypeError:
data[idx] = ''
# Skip empty rows
if empty:
continue
row = {
'data': data,
'index': i
}
rows.append(row)
return rows
def get_row_data(self, index):
""" Retrieve row data at a particular index """
if self.data is None or index >= len(self.data):
return None
return self.data[index]
def get_row_dict(self, index):
""" Retrieve a dict object representing the data row at a particular offset """
if self.data is None or index >= len(self.data):
return None
return self.data.dict[index]

View File

@ -5,8 +5,16 @@ Django forms for interacting with common objects
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from decimal import Decimal, InvalidOperation
from django import forms
from django.utils.translation import gettext as _
from djmoney.forms.fields import MoneyField
from InvenTree.forms import HelperForm from InvenTree.forms import HelperForm
from .files import FileManager
from .models import InvenTreeSetting from .models import InvenTreeSetting
@ -21,3 +29,183 @@ class SettingEditForm(HelperForm):
fields = [ fields = [
'value' 'value'
] ]
class UploadFile(forms.Form):
""" Step 1 of FileManagementFormView """
file = forms.FileField(
label=_('File'),
help_text=_('Select file to upload'),
)
def __init__(self, *args, **kwargs):
""" Update label and help_text """
# Get file name
name = None
if 'name' in kwargs:
name = kwargs.pop('name')
super().__init__(*args, **kwargs)
if name:
# Update label and help_text with file name
self.fields['file'].label = _(f'{name.title()} File')
self.fields['file'].help_text = _(f'Select {name} file to upload')
def clean_file(self):
"""
Run tabular file validation.
If anything is wrong with the file, it will raise ValidationError
"""
file = self.cleaned_data['file']
# Validate file using FileManager class - will perform initial data validation
# (and raise a ValidationError if there is something wrong with the file)
FileManager.validate(file)
return file
class MatchField(forms.Form):
""" Step 2 of FileManagementFormView """
def __init__(self, *args, **kwargs):
# Get FileManager
file_manager = None
if 'file_manager' in kwargs:
file_manager = kwargs.pop('file_manager')
super().__init__(*args, **kwargs)
# Setup FileManager
file_manager.setup()
# Get columns
columns = file_manager.columns()
# Get headers choices
headers_choices = [(header, header) for header in file_manager.HEADERS]
# Create column fields
for col in columns:
field_name = col['name']
self.fields[field_name] = forms.ChoiceField(
choices=[('', '-' * 10)] + headers_choices,
required=False,
widget=forms.Select(attrs={
'class': 'select fieldselect',
})
)
if col['guess']:
self.fields[field_name].initial = col['guess']
class MatchItem(forms.Form):
""" Step 3 of FileManagementFormView """
def __init__(self, *args, **kwargs):
# Get FileManager
file_manager = None
if 'file_manager' in kwargs:
file_manager = kwargs.pop('file_manager')
if 'row_data' in kwargs:
row_data = kwargs.pop('row_data')
else:
row_data = None
super().__init__(*args, **kwargs)
def clean(number):
""" Clean-up decimal value """
# Check if empty
if not number:
return number
# Check if decimal type
try:
clean_number = Decimal(number)
except InvalidOperation:
clean_number = number
return clean_number.quantize(Decimal(1)) if clean_number == clean_number.to_integral() else clean_number.normalize()
# Setup FileManager
file_manager.setup()
# Create fields
if row_data:
# Navigate row data
for row in row_data:
# Navigate column data
for col in row['data']:
# Get column matching
col_guess = col['column'].get('guess', None)
# Create input for required headers
if col_guess in file_manager.REQUIRED_HEADERS:
# Set field name
field_name = col_guess.lower() + '-' + str(row['index'])
# Set field input box
if 'quantity' in col_guess.lower():
self.fields[field_name] = forms.CharField(
required=False,
widget=forms.NumberInput(attrs={
'name': 'quantity' + str(row['index']),
'class': 'numberinput', # form-control',
'type': 'number',
'min': '0',
'step': 'any',
'value': clean(row.get('quantity', '')),
})
)
# Create item selection box
elif col_guess in file_manager.ITEM_MATCH_HEADERS:
# Get item options
item_options = [(option.id, option) for option in row['item_options']]
# Get item match
item_match = row['item_match']
# Set field name
field_name = 'item_select-' + str(row['index'])
# Set field select box
self.fields[field_name] = forms.ChoiceField(
choices=[('', '-' * 10)] + item_options,
required=False,
widget=forms.Select(attrs={
'class': 'select bomselect',
})
)
# Update select box when match was found
if item_match:
# Make it a required field: does not validate if
# removed using JS function
# self.fields[field_name].required = True
# Update initial value
self.fields[field_name].initial = item_match.id
# Optional entries
elif col_guess in file_manager.OPTIONAL_HEADERS:
# Set field name
field_name = col_guess.lower() + '-' + str(row['index'])
# Get value
value = row.get(col_guess.lower(), '')
# Set field input box
if 'price' in col_guess.lower():
self.fields[field_name] = MoneyField(
label=_(col_guess),
default_currency=InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY'),
decimal_places=5,
max_digits=19,
required=False,
default_amount=clean(value),
)
else:
self.fields[field_name] = forms.CharField(
required=False,
initial=value,
)

View File

@ -5,14 +5,21 @@ Django views for interacting with common models
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
import os
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.forms import CheckboxInput, Select from django.forms import CheckboxInput, Select
from django.conf import settings
from django.core.files.storage import FileSystemStorage
from formtools.wizard.views import SessionWizardView
from InvenTree.views import AjaxUpdateView from InvenTree.views import AjaxUpdateView
from InvenTree.helpers import str2bool from InvenTree.helpers import str2bool
from . import models from . import models
from . import forms from . import forms
from .files import FileManager
class SettingEdit(AjaxUpdateView): class SettingEdit(AjaxUpdateView):
@ -101,3 +108,392 @@ class SettingEdit(AjaxUpdateView):
if not str2bool(value, test=True) and not str2bool(value, test=False): if not str2bool(value, test=True) and not str2bool(value, test=False):
form.add_error('value', _('Supplied value must be a boolean')) form.add_error('value', _('Supplied value must be a boolean'))
class MultiStepFormView(SessionWizardView):
""" Setup basic methods of multi-step form
form_list: list of forms
form_steps_description: description for each form
"""
form_list = []
form_steps_template = []
form_steps_description = []
file_manager = None
media_folder = ''
file_storage = FileSystemStorage(settings.MEDIA_ROOT)
def __init__(self, *args, **kwargs):
""" Override init method to set media folder """
super().__init__(*args, **kwargs)
self.process_media_folder()
def process_media_folder(self):
""" Process media folder """
if self.media_folder:
media_folder_abs = os.path.join(settings.MEDIA_ROOT, self.media_folder)
if not os.path.exists(media_folder_abs):
os.mkdir(media_folder_abs)
self.file_storage = FileSystemStorage(location=media_folder_abs)
def get_template_names(self):
""" Select template """
try:
# Get template
template = self.form_steps_template[self.steps.index]
except IndexError:
return self.template_name
return template
def get_context_data(self, **kwargs):
""" Update context data """
# Retrieve current context
context = super().get_context_data(**kwargs)
# Get form description
try:
description = self.form_steps_description[self.steps.index]
except IndexError:
description = ''
# Add description to form steps
context.update({'description': description})
return context
class FileManagementFormView(MultiStepFormView):
""" Setup form wizard to perform the following steps:
1. Upload tabular data file
2. Match headers to InvenTree fields
3. Edit row data and match InvenTree items
"""
name = None
form_list = [
('upload', forms.UploadFile),
('fields', forms.MatchField),
('items', forms.MatchItem),
]
form_steps_description = [
_("Upload File"),
_("Match Fields"),
_("Match Items"),
]
media_folder = 'file_upload/'
extra_context_data = {}
def get_context_data(self, form, **kwargs):
context = super().get_context_data(form=form, **kwargs)
if self.steps.current in ('fields', 'items'):
# Get columns and row data
self.columns = self.file_manager.columns()
self.rows = self.file_manager.rows()
# Check for stored data
stored_data = self.storage.get_step_data(self.steps.current)
if stored_data:
self.get_form_table_data(stored_data)
elif self.steps.current == 'items':
# Set form table data
self.set_form_table_data(form=form)
# Update context
context.update({'rows': self.rows})
context.update({'columns': self.columns})
# Load extra context data
for key, items in self.extra_context_data.items():
context.update({key: items})
return context
def get_file_manager(self, step=None, form=None):
""" Get FileManager instance from uploaded file """
if self.file_manager:
return
if step is not None:
# Retrieve stored files from upload step
upload_files = self.storage.get_step_files('upload')
if upload_files:
# Get file
file = upload_files.get('upload-file', None)
if file:
self.file_manager = FileManager(file=file, name=self.name)
def get_form_kwargs(self, step=None):
""" Update kwargs to dynamically build forms """
# Always retrieve FileManager instance from uploaded file
self.get_file_manager(step)
if step == 'upload':
# Dynamically build upload form
if self.name:
kwargs = {
'name': self.name
}
return kwargs
elif step == 'fields':
# Dynamically build match field form
kwargs = {
'file_manager': self.file_manager
}
return kwargs
elif step == 'items':
# Dynamically build match item form
kwargs = {}
kwargs['file_manager'] = self.file_manager
# Get data from fields step
data = self.storage.get_step_data('fields')
# Process to update columns and rows
self.rows = self.file_manager.rows()
self.columns = self.file_manager.columns()
self.get_form_table_data(data)
self.set_form_table_data()
self.get_field_selection()
kwargs['row_data'] = self.rows
return kwargs
return super().get_form_kwargs()
def get_form_table_data(self, form_data):
""" Extract table cell data from form data and fields.
These data are used to maintain state between sessions.
Table data keys are as follows:
col_name_<idx> - Column name at idx as provided in the uploaded file
col_guess_<idx> - Column guess at idx as selected
row_<x>_col<y> - Cell data as provided in the uploaded file
"""
# Map the columns
self.column_names = {}
self.column_selections = {}
self.row_data = {}
for item, value in form_data.items():
# Column names as passed as col_name_<idx> where idx is an integer
# Extract the column names
if item.startswith('col_name_'):
try:
col_id = int(item.replace('col_name_', ''))
except ValueError:
continue
self.column_names[col_id] = value
# Extract the column selections (in the 'select fields' view)
if item.startswith('fields-'):
try:
col_name = item.replace('fields-', '')
except ValueError:
continue
for idx, name in self.column_names.items():
if name == col_name:
self.column_selections[idx] = value
break
# Extract the row data
if item.startswith('row_'):
# Item should be of the format row_<r>_col_<c>
s = item.split('_')
if len(s) < 4:
continue
# Ignore row/col IDs which are not correct numeric values
try:
row_id = int(s[1])
col_id = int(s[3])
except ValueError:
continue
if row_id not in self.row_data:
self.row_data[row_id] = {}
self.row_data[row_id][col_id] = value
def set_form_table_data(self, form=None):
""" Set the form table data """
if self.column_names:
# Re-construct the column data
self.columns = []
for idx, value in self.column_names.items():
header = ({
'name': value,
'guess': self.column_selections.get(idx, ''),
})
self.columns.append(header)
if self.row_data:
# Re-construct the row data
self.rows = []
# Update the row data
for row_idx, row_key in enumerate(sorted(self.row_data.keys())):
row_data = self.row_data[row_key]
data = []
for idx, item in row_data.items():
column_data = {
'name': self.column_names[idx],
'guess': self.column_selections[idx],
}
cell_data = {
'cell': item,
'idx': idx,
'column': column_data,
}
data.append(cell_data)
row = {
'index': row_idx,
'data': data,
'errors': {},
}
self.rows.append(row)
# In the item selection step: update row data with mapping to form fields
if form and self.steps.current == 'items':
# Find field keys
field_keys = []
for field in form.fields:
field_key = field.split('-')[0]
if field_key not in field_keys:
field_keys.append(field_key)
# Populate rows
for row in self.rows:
for field_key in field_keys:
# Map row data to field
row[field_key] = field_key + '-' + str(row['index'])
def get_column_index(self, name):
""" Return the index of the column with the given name.
It named column is not found, return -1
"""
try:
idx = list(self.column_selections.values()).index(name)
except ValueError:
idx = -1
return idx
def get_field_selection(self):
""" Once data columns have been selected, attempt to pre-select the proper data from the database.
This function is called once the field selection has been validated.
The pre-fill data are then passed through to the part selection form.
This method is very specific to the type of data found in the file,
therefore overwrite it in the subclass.
"""
pass
def check_field_selection(self, form):
""" Check field matching """
# Are there any missing columns?
missing_columns = []
# Check that all required fields are present
for col in self.file_manager.REQUIRED_HEADERS:
if col not in self.column_selections.values():
missing_columns.append(col)
# Check that at least one of the part match field is present
part_match_found = False
for col in self.file_manager.ITEM_MATCH_HEADERS:
if col in self.column_selections.values():
part_match_found = True
break
# If not, notify user
if not part_match_found:
for col in self.file_manager.ITEM_MATCH_HEADERS:
missing_columns.append(col)
# Track any duplicate column selections
duplicates = []
for col in self.column_names:
if col in self.column_selections:
guess = self.column_selections[col]
else:
guess = None
if guess:
n = list(self.column_selections.values()).count(self.column_selections[col])
if n > 1:
duplicates.append(col)
# Store extra context data
self.extra_context_data = {
'missing_columns': missing_columns,
'duplicates': duplicates,
}
# Data validation
valid = not missing_columns and not duplicates
return valid
def validate(self, step, form):
""" Validate forms """
valid = True
# Get form table data
self.get_form_table_data(form.data)
if step == 'fields':
# Validate user form data
valid = self.check_field_selection(form)
if not valid:
form.add_error(None, _('Fields matching failed'))
elif step == 'items':
pass
return valid
def post(self, request, *args, **kwargs):
""" Perform validations before posting data """
wizard_goto_step = self.request.POST.get('wizard_goto_step', None)
form = self.get_form(data=self.request.POST, files=self.request.FILES)
form_valid = self.validate(self.steps.current, form)
if not form_valid and not wizard_goto_step:
# Re-render same step
return self.render(form)
return super().post(*args, **kwargs)

View File

@ -28,7 +28,7 @@ from company.models import Company, SupplierPart
from InvenTree.fields import RoundingDecimalField from InvenTree.fields import RoundingDecimalField
from InvenTree.helpers import decimal2string, increment, getSetting from InvenTree.helpers import decimal2string, increment, getSetting
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus, StockHistoryCode
from InvenTree.models import InvenTreeAttachment from InvenTree.models import InvenTreeAttachment
@ -336,10 +336,12 @@ class PurchaseOrder(Order):
return self.pending_line_items().count() == 0 return self.pending_line_items().count() == 0
@transaction.atomic @transaction.atomic
def receive_line_item(self, line, location, quantity, user, status=StockStatus.OK, purchase_price=None): def receive_line_item(self, line, location, quantity, user, status=StockStatus.OK, purchase_price=None, **kwargs):
""" Receive a line item (or partial line item) against this PO """ Receive a line item (or partial line item) against this PO
""" """
notes = kwargs.get('notes', '')
if not self.status == PurchaseOrderStatus.PLACED: if not self.status == PurchaseOrderStatus.PLACED:
raise ValidationError({"status": _("Lines can only be received against an order marked as 'Placed'")}) raise ValidationError({"status": _("Lines can only be received against an order marked as 'Placed'")})
@ -364,13 +366,22 @@ class PurchaseOrder(Order):
purchase_price=purchase_price, purchase_price=purchase_price,
) )
stock.save() stock.save(add_note=False)
text = _("Received items") tracking_info = {
note = _('Received {n} items against order {name}').format(n=quantity, name=str(self)) 'status': status,
'purchaseorder': self.pk,
}
# Add a new transaction note to the newly created stock item stock.add_tracking_entry(
stock.addTransactionNote(text, user, note) StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER,
user,
notes=notes,
deltas=tracking_info,
location=location,
purchaseorder=self,
quantity=quantity
)
# Update the number of parts received against the particular line item # Update the number of parts received against the particular line item
line.received += quantity line.received += quantity

View File

@ -0,0 +1,99 @@
{% extends "order/order_wizard/po_upload.html" %}
{% load inventree_extras %}
{% load i18n %}
{% load static %}
{% block form_alert %}
{% if missing_columns and missing_columns|length > 0 %}
<div class='alert alert-danger alert-block' role='alert'>
{% trans "Missing selections for the following required columns" %}:
<br>
<ul>
{% for col in missing_columns %}
<li>{{ col }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if duplicates and duplicates|length > 0 %}
<div class='alert alert-danger alert-block' role='alert'>
{% trans "Duplicate selections found, see below. Fix them then retry submitting." %}
</div>
{% endif %}
{% endblock form_alert %}
{% block form_buttons_top %}
{% if wizard.steps.prev %}
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-default">{% trans "Previous Step" %}</button>
{% endif %}
<button type="submit" class="save btn btn-default">{% trans "Submit Selections" %}</button>
{% endblock form_buttons_top %}
{% block form_content %}
<thead>
<tr>
<th>{% trans "File Fields" %}</th>
<th></th>
{% for col in form %}
<th>
<div>
<input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/>
{{ col.name }}
<button class='btn btn-default btn-remove' onClick='removeColFromBomWizard()' id='del_col_{{ forloop.counter0 }}' style='display: inline; float: right;' title='{% trans "Remove column" %}'>
<span col_id='{{ forloop.counter0 }}' class='fas fa-trash-alt icon-red'></span>
</button>
</div>
</th>
{% endfor %}
</tr>
</thead>
<tbody>
<tr>
<td>{% trans "Match Fields" %}</td>
<td></td>
{% for col in form %}
<td>
{{ col }}
{% for duplicate in duplicates %}
{% if duplicate == col.name %}
<div class='alert alert-danger alert-block text-center' role='alert' style='padding:2px; margin-top:6px; margin-bottom:2px'>
<b>{% trans "Duplicate selection" %}</b>
</div>
{% endif %}
{% endfor %}
</td>
{% endfor %}
</tr>
{% for row in rows %}
{% with forloop.counter as row_index %}
<tr>
<td style='width: 32px;'>
<button class='btn btn-default btn-remove' onClick='removeRowFromBomWizard()' id='del_row_{{ row_index }}' style='display: inline; float: left;' title='{% trans "Remove row" %}'>
<span row_id='{{ row_index }}' class='fas fa-trash-alt icon-red'></span>
</button>
</td>
<td style='text-align: left;'>{{ row_index }}</td>
{% for item in row.data %}
<td>
<input type='hidden' name='row_{{ row_index }}_col_{{ forloop.counter0 }}' value='{{ item }}'/>
{{ item }}
</td>
{% endfor %}
</tr>
{% endwith %}
{% endfor %}
</tbody>
{% endblock form_content %}
{% block form_buttons_bottom %}
{% endblock form_buttons_bottom %}
{% block js_ready %}
{{ block.super }}
$('.fieldselect').select2({
width: '100%',
matcher: partialMatcher,
});
{% endblock %}

View File

@ -0,0 +1,125 @@
{% extends "order/order_wizard/po_upload.html" %}
{% load inventree_extras %}
{% load i18n %}
{% load static %}
{% block form_alert %}
{% if form.errors %}
{% endif %}
{% if form_errors %}
<div class='alert alert-danger alert-block' role='alert'>
{% trans "Errors exist in the submitted data" %}
</div>
{% endif %}
{% endblock form_alert %}
{% block form_buttons_top %}
{% if wizard.steps.prev %}
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-default">{% trans "Previous Step" %}</button>
{% endif %}
<button type="submit" class="save btn btn-default">{% trans "Submit Selections" %}</button>
{% endblock form_buttons_top %}
{% block form_content %}
<thead>
<tr>
<th></th>
<th>{% trans "Row" %}</th>
<th>{% trans "Select Supplier Part" %}</th>
<th>{% trans "Quantity" %}</th>
{% for col in columns %}
{% if col.guess != 'Quantity' %}
<th>
<input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/>
<input type='hidden' name='col_guess_{{ forloop.counter0 }}' value='{{ col.guess }}'/>
{% if col.guess %}
{{ col.guess }}
{% else %}
{{ col.name }}
{% endif %}
</th>
{% endif %}
{% endfor %}
</tr>
</thead>
<tbody>
<tr></tr> {% comment %} Dummy row for javascript del_row method {% endcomment %}
{% for row in rows %}
<tr {% if row.errors %} style='background: #ffeaea;'{% endif %} part-select='#select_part_{{ row.index }}'>
<td>
<button class='btn btn-default btn-remove' onClick='removeRowFromBomWizard()' id='del_row_{{ row.index }}' style='display: inline; float: right;' title='{% trans "Remove row" %}'>
<span row_id='{{ row.index }}' class='fas fa-trash-alt icon-red'></span>
</button>
</td>
<td>
{% add row.index 1 %}
</td>
<td>
{% for field in form.visible_fields %}
{% if field.name == row.item_select %}
{{ field }}
{% endif %}
{% endfor %}
{% if row.errors.part %}
<p class='help-inline'>{{ row.errors.part }}</p>
{% endif %}
</td>
<td>
{% for field in form.visible_fields %}
{% if field.name == row.quantity %}
{{ field }}
{% endif %}
{% endfor %}
{% if row.errors.quantity %}
<p class='help-inline'>{{ row.errors.quantity }}</p>
{% endif %}
</td>
{% for item in row.data %}
{% if item.column.guess != 'Quantity' %}
<td>
{% if item.column.guess == 'Purchase_Price' %}
{% for field in form.visible_fields %}
{% if field.name == row.purchase_price %}
{{ field }}
{% endif %}
{% endfor %}
{% elif item.column.guess == 'Reference' %}
{% for field in form.visible_fields %}
{% if field.name == row.reference %}
{{ field }}
{% endif %}
{% endfor %}
{% elif item.column.guess == 'Notes' %}
{% for field in form.visible_fields %}
{% if field.name == row.notes %}
{{ field }}
{% endif %}
{% endfor %}
{% else %}
{{ item.cell }}
{% endif %}
<input type='hidden' name='row_{{ row.index }}_col_{{ forloop.counter0 }}' value='{{ item.cell }}'/>
</td>
{% endif %}
{% endfor %}
</tr>
{% endfor %}
</tbody>
{% endblock form_content %}
{% block form_buttons_bottom %}
{% endblock form_buttons_bottom %}
{% block js_ready %}
{{ block.super }}
$('.bomselect').select2({
dropdownAutoWidth: true,
matcher: partialMatcher,
});
$('.currencyselect').select2({
dropdownAutoWidth: true,
});
{% endblock %}

View File

@ -0,0 +1,56 @@
{% extends "order/order_base.html" %}
{% load inventree_extras %}
{% load i18n %}
{% load static %}
{% block menubar %}
{% include 'order/po_navbar.html' with tab='upload' %}
{% endblock %}
{% block heading %}
{% trans "Upload File for Purchase Order" %}
{{ wizard.form.media }}
{% endblock %}
{% block details %}
{% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change %}
<p>{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %}
{% if description %}- {{ description }}{% endif %}</p>
{% block form_alert %}
{% endblock form_alert %}
<form action="" method="post" class='js-modal-form' enctype="multipart/form-data">
{% csrf_token %}
{% load crispy_forms_tags %}
{% block form_buttons_top %}
{% endblock form_buttons_top %}
<table class='table table-striped' style='margin-top: 12px; margin-bottom: 0px'>
{{ wizard.management_form }}
{% block form_content %}
{% crispy wizard.form %}
{% endblock form_content %}
</table>
{% block form_buttons_bottom %}
{% if wizard.steps.prev %}
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-default">{% trans "Previous Step" %}</button>
{% endif %}
<button type="submit" class="save btn btn-default">{% trans "Upload File" %}</button>
</form>
{% endblock form_buttons_bottom %}
{% else %}
<div class='alert alert-danger alert-block' role='alert'>
{% trans "Order is already processed. Files cannot be uploaded." %}
</div>
{% endif %}
{% endblock details %}
{% block js_ready %}
{{ block.super }}
{% endblock %}

View File

@ -1,6 +1,7 @@
{% load i18n %} {% load i18n %}
{% load static %} {% load static %}
{% load inventree_extras %} {% load inventree_extras %}
{% load status_codes %}
<ul class='list-group'> <ul class='list-group'>
<li class='list-group-item'> <li class='list-group-item'>
@ -14,6 +15,14 @@
{% trans "Details" %} {% trans "Details" %}
</a> </a>
</li> </li>
{% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change %}
<li class='list-group-item {% if tab == "upload" %}active{% endif %}' title='{% trans "Upload File" %}'>
<a href='{% url "po-upload" order.id %}'>
<span class='fas fa-file-upload'></span>
{% trans "Upload File" %}
</a>
</li>
{% endif %}
<li class='list-group-item {% if tab == "received" %}active{% endif %}' title='{% trans "Received Stock Items" %}'> <li class='list-group-item {% if tab == "received" %}active{% endif %}' title='{% trans "Received Stock Items" %}'>
<a href='{% url "po-received" order.id %}'> <a href='{% url "po-received" order.id %}'>
<span class='fas fa-sign-in-alt'></span> <span class='fas fa-sign-in-alt'></span>

View File

@ -17,6 +17,7 @@ purchase_order_detail_urls = [
url(r'^receive/', views.PurchaseOrderReceive.as_view(), name='po-receive'), url(r'^receive/', views.PurchaseOrderReceive.as_view(), name='po-receive'),
url(r'^complete/', views.PurchaseOrderComplete.as_view(), name='po-complete'), url(r'^complete/', views.PurchaseOrderComplete.as_view(), name='po-complete'),
url(r'^upload/', views.PurchaseOrderUpload.as_view(), name='po-upload'),
url(r'^export/', views.PurchaseOrderExport.as_view(), name='po-export'), url(r'^export/', views.PurchaseOrderExport.as_view(), name='po-export'),
url(r'^notes/', views.PurchaseOrderNotes.as_view(), name='po-notes'), url(r'^notes/', views.PurchaseOrderNotes.as_view(), name='po-notes'),

View File

@ -6,10 +6,12 @@ Django views for interacting with Order app
from __future__ import unicode_literals from __future__ import unicode_literals
from django.db import transaction from django.db import transaction
from django.db.utils import IntegrityError
from django.http.response import JsonResponse from django.http.response import JsonResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.urls import reverse from django.urls import reverse
from django.http import HttpResponseRedirect
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.views.generic import DetailView, ListView, UpdateView from django.views.generic import DetailView, ListView, UpdateView
from django.views.generic.edit import FormMixin from django.views.generic.edit import FormMixin
@ -23,11 +25,12 @@ from .models import SalesOrder, SalesOrderLineItem, SalesOrderAttachment
from .models import SalesOrderAllocation from .models import SalesOrderAllocation
from .admin import POLineItemResource from .admin import POLineItemResource
from build.models import Build from build.models import Build
from company.models import Company, SupplierPart from company.models import Company, SupplierPart # ManufacturerPart
from stock.models import StockItem, StockLocation from stock.models import StockItem, StockLocation
from part.models import Part from part.models import Part
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
from common.views import FileManagementFormView
from . import forms as order_forms from . import forms as order_forms
from part.views import PartPricing from part.views import PartPricing
@ -566,6 +569,192 @@ class SalesOrderShip(AjaxUpdateView):
return self.renderJsonResponse(request, form, data, context) return self.renderJsonResponse(request, form, data, context)
class PurchaseOrderUpload(FileManagementFormView):
''' PurchaseOrder: Upload file, match to fields and parts (using multi-Step form) '''
name = 'order'
form_steps_template = [
'order/order_wizard/po_upload.html',
'order/order_wizard/match_fields.html',
'order/order_wizard/match_parts.html',
]
form_steps_description = [
_("Upload File"),
_("Match Fields"),
_("Match Supplier Parts"),
]
# Form field name: PurchaseOrderLineItem field
form_field_map = {
'item_select': 'part',
'quantity': 'quantity',
'purchase_price': 'purchase_price',
'reference': 'reference',
'notes': 'notes',
}
def get_order(self):
""" Get order or return 404 """
return get_object_or_404(PurchaseOrder, pk=self.kwargs['pk'])
def get_context_data(self, form, **kwargs):
context = super().get_context_data(form=form, **kwargs)
order = self.get_order()
context.update({'order': order})
return context
def get_field_selection(self):
""" Once data columns have been selected, attempt to pre-select the proper data from the database.
This function is called once the field selection has been validated.
The pre-fill data are then passed through to the SupplierPart selection form.
"""
order = self.get_order()
self.allowed_items = SupplierPart.objects.filter(supplier=order.supplier).prefetch_related('manufacturer_part')
# Fields prefixed with "Part_" can be used to do "smart matching" against Part objects in the database
q_idx = self.get_column_index('Quantity')
s_idx = self.get_column_index('Supplier_SKU')
m_idx = self.get_column_index('Manufacturer_MPN')
p_idx = self.get_column_index('Purchase_Price')
r_idx = self.get_column_index('Reference')
n_idx = self.get_column_index('Notes')
for row in self.rows:
# Initially use a quantity of zero
quantity = Decimal(0)
# Initially we do not have a part to reference
exact_match_part = None
# Check if there is a column corresponding to "quantity"
if q_idx >= 0:
q_val = row['data'][q_idx]['cell']
if q_val:
# Delete commas
q_val = q_val.replace(',', '')
try:
# Attempt to extract a valid quantity from the field
quantity = Decimal(q_val)
# Store the 'quantity' value
row['quantity'] = quantity
except (ValueError, InvalidOperation):
pass
# Check if there is a column corresponding to "Supplier SKU"
if s_idx >= 0:
sku = row['data'][s_idx]['cell']
try:
# Attempt SupplierPart lookup based on SKU value
exact_match_part = self.allowed_items.get(SKU__contains=sku)
except (ValueError, SupplierPart.DoesNotExist, SupplierPart.MultipleObjectsReturned):
exact_match_part = None
# Check if there is a column corresponding to "Manufacturer MPN" and no exact match found yet
if m_idx >= 0 and not exact_match_part:
mpn = row['data'][m_idx]['cell']
try:
# Attempt SupplierPart lookup based on MPN value
exact_match_part = self.allowed_items.get(manufacturer_part__MPN__contains=mpn)
except (ValueError, SupplierPart.DoesNotExist, SupplierPart.MultipleObjectsReturned):
exact_match_part = None
# Supply list of part options for each row, sorted by how closely they match the part name
row['item_options'] = self.allowed_items
# Unless found, the 'part_match' is blank
row['item_match'] = None
if exact_match_part:
# If there is an exact match based on SKU or MPN, use that
row['item_match'] = exact_match_part
# Check if there is a column corresponding to "purchase_price"
if p_idx >= 0:
p_val = row['data'][p_idx]['cell']
if p_val:
# Delete commas
p_val = p_val.replace(',', '')
try:
# Attempt to extract a valid decimal value from the field
purchase_price = Decimal(p_val)
# Store the 'purchase_price' value
row['purchase_price'] = purchase_price
except (ValueError, InvalidOperation):
pass
# Check if there is a column corresponding to "reference"
if r_idx >= 0:
reference = row['data'][r_idx]['cell']
row['reference'] = reference
# Check if there is a column corresponding to "notes"
if n_idx >= 0:
notes = row['data'][n_idx]['cell']
row['notes'] = notes
def done(self, form_list, **kwargs):
""" Once all the data is in, process it to add PurchaseOrderLineItem instances to the order """
order = self.get_order()
items = {}
for form_key, form_value in self.get_all_cleaned_data().items():
# Split key from row value
try:
(field, idx) = form_key.split('-')
except ValueError:
continue
if idx not in items:
# Insert into items
items.update({
idx: {
self.form_field_map[field]: form_value,
}
})
else:
# Update items
items[idx][self.form_field_map[field]] = form_value
# Create PurchaseOrderLineItem instances
for purchase_order_item in items.values():
try:
supplier_part = SupplierPart.objects.get(pk=int(purchase_order_item['part']))
except (ValueError, SupplierPart.DoesNotExist):
continue
quantity = purchase_order_item.get('quantity', 0)
if quantity:
purchase_order_line_item = PurchaseOrderLineItem(
order=order,
part=supplier_part,
quantity=quantity,
purchase_price=purchase_order_item.get('purchase_price', None),
reference=purchase_order_item.get('reference', ''),
notes=purchase_order_item.get('notes', ''),
)
try:
purchase_order_line_item.save()
except IntegrityError:
# PurchaseOrderLineItem already exists
pass
return HttpResponseRedirect(reverse('po-detail', kwargs={'pk': self.kwargs['pk']}))
class PurchaseOrderExport(AjaxView): class PurchaseOrderExport(AjaxView):
""" File download for a purchase order """ File download for a purchase order

View File

@ -130,7 +130,7 @@ class StockAttachmentAdmin(admin.ModelAdmin):
class StockTrackingAdmin(ImportExportModelAdmin): class StockTrackingAdmin(ImportExportModelAdmin):
list_display = ('item', 'date', 'title') list_display = ('item', 'date', 'label')
class StockItemTestResultAdmin(admin.ModelAdmin): class StockItemTestResultAdmin(admin.ModelAdmin):

View File

@ -21,8 +21,11 @@ from .models import StockItemTestResult
from part.models import Part, PartCategory from part.models import Part, PartCategory
from part.serializers import PartBriefSerializer from part.serializers import PartBriefSerializer
from company.models import SupplierPart from company.models import Company, SupplierPart
from company.serializers import SupplierPartSerializer from company.serializers import CompanySerializer, SupplierPartSerializer
from order.models import PurchaseOrder
from order.serializers import POSerializer
import common.settings import common.settings
import common.models import common.models
@ -97,6 +100,16 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
return self.serializer_class(*args, **kwargs) return self.serializer_class(*args, **kwargs)
def update(self, request, *args, **kwargs):
"""
Record the user who updated the item
"""
# TODO: Record the user!
# user = request.user
return super().update(request, *args, **kwargs)
class StockFilter(FilterSet): class StockFilter(FilterSet):
""" FilterSet for advanced stock filtering. """ FilterSet for advanced stock filtering.
@ -371,25 +384,26 @@ class StockList(generics.ListCreateAPIView):
we can pre-fill the location automatically. we can pre-fill the location automatically.
""" """
user = request.user
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
# TODO - Save the user who created this item
item = serializer.save() item = serializer.save()
# A location was *not* specified - try to infer it # A location was *not* specified - try to infer it
if 'location' not in request.data: if 'location' not in request.data:
location = item.part.get_default_location() item.location = item.part.get_default_location()
if location is not None:
item.location = location
item.save()
# An expiry date was *not* specified - try to infer it! # An expiry date was *not* specified - try to infer it!
if 'expiry_date' not in request.data: if 'expiry_date' not in request.data:
if item.part.default_expiry > 0: if item.part.default_expiry > 0:
item.expiry_date = datetime.now().date() + timedelta(days=item.part.default_expiry) item.expiry_date = datetime.now().date() + timedelta(days=item.part.default_expiry)
item.save()
# Finally, save the item
item.save(user=user)
# Return a response # Return a response
headers = self.get_success_headers(serializer.data) headers = self.get_success_headers(serializer.data)
@ -965,7 +979,7 @@ class StockItemTestResultList(generics.ListCreateAPIView):
test_result.save() test_result.save()
class StockTrackingList(generics.ListCreateAPIView): class StockTrackingList(generics.ListAPIView):
""" API endpoint for list view of StockItemTracking objects. """ API endpoint for list view of StockItemTracking objects.
StockItemTracking objects are read-only StockItemTracking objects are read-only
@ -992,6 +1006,59 @@ class StockTrackingList(generics.ListCreateAPIView):
return self.serializer_class(*args, **kwargs) return self.serializer_class(*args, **kwargs)
def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset())
serializer = self.get_serializer(queryset, many=True)
data = serializer.data
# Attempt to add extra context information to the historical data
for item in data:
deltas = item['deltas']
# Add location detail
if 'location' in deltas:
try:
location = StockLocation.objects.get(pk=deltas['location'])
serializer = LocationSerializer(location)
deltas['location_detail'] = serializer.data
except:
pass
# Add stockitem detail
if 'stockitem' in deltas:
try:
stockitem = StockItem.objects.get(pk=deltas['stockitem'])
serializer = StockItemSerializer(stockitem)
deltas['stockitem_detail'] = serializer.data
except:
pass
# Add customer detail
if 'customer' in deltas:
try:
customer = Company.objects.get(pk=deltas['customer'])
serializer = CompanySerializer(customer)
deltas['customer_detail'] = serializer.data
except:
pass
# Add purchaseorder detail
if 'purchaseorder' in deltas:
try:
order = PurchaseOrder.objects.get(pk=deltas['purchaseorder'])
serializer = POSerializer(order)
deltas['purchaseorder_detail'] = serializer.data
except:
pass
if request.is_ajax():
return JsonResponse(data, safe=False)
else:
return Response(data)
def create(self, request, *args, **kwargs): def create(self, request, *args, **kwargs):
""" Create a new StockItemTracking object """ Create a new StockItemTracking object

View File

@ -393,6 +393,18 @@ class AdjustStockForm(forms.ModelForm):
] ]
class EditStockItemStatusForm(HelperForm):
"""
Simple form for editing StockItem status field
"""
class Meta:
model = StockItem
fields = [
'status',
]
class EditStockItemForm(HelperForm): class EditStockItemForm(HelperForm):
""" Form for editing a StockItem object. """ Form for editing a StockItem object.
Note that not all fields can be edited here (even if they can be specified during creation. Note that not all fields can be edited here (even if they can be specified during creation.
@ -425,14 +437,15 @@ class EditStockItemForm(HelperForm):
class TrackingEntryForm(HelperForm): class TrackingEntryForm(HelperForm):
""" Form for creating / editing a StockItemTracking object. """
Form for creating / editing a StockItemTracking object.
Note: 2021-05-11 - This form is not currently used - should delete?
""" """
class Meta: class Meta:
model = StockItemTracking model = StockItemTracking
fields = [ fields = [
'title',
'notes', 'notes',
'link',
] ]

View File

@ -0,0 +1,28 @@
# Generated by Django 3.2 on 2021-05-11 07:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stock', '0059_auto_20210404_2016'),
]
operations = [
migrations.AddField(
model_name='stockitemtracking',
name='deltas',
field=models.JSONField(blank=True, null=True),
),
migrations.AddField(
model_name='stockitemtracking',
name='tracking_type',
field=models.IntegerField(default=0),
),
migrations.AlterField(
model_name='stockitemtracking',
name='title',
field=models.CharField(blank=True, help_text='Tracking entry title', max_length=250, verbose_name='Title'),
),
]

View File

@ -0,0 +1,219 @@
# Generated by Django 3.2 on 2021-05-10 23:11
import re
from django.db import migrations
from InvenTree.status_codes import StockHistoryCode
def update_history(apps, schema_editor):
"""
Update each existing StockItemTracking object,
convert the recorded "quantity" to a delta
"""
StockItem = apps.get_model('stock', 'stockitem')
StockItemTracking = apps.get_model('stock', 'stockitemtracking')
StockLocation = apps.get_model('stock', 'stocklocation')
update_count = 0
locations = StockLocation.objects.all()
for location in locations:
# Pre-calculate pathstring
# Note we cannot use the 'pathstring' function here as we don't have access to model functions!
path = [location.name]
loc = location
while loc.parent:
loc = loc.parent
path = [loc.name] + path
location._path = '/'.join(path)
for item in StockItem.objects.all():
history = StockItemTracking.objects.filter(item=item).order_by('date')
if history.count() == 0:
continue
quantity = history[0].quantity
for idx, entry in enumerate(history):
deltas = {}
updated = False
q = entry.quantity
if idx == 0 or not q == quantity:
try:
deltas['quantity']: float(q)
updated = True
except:
print(f"WARNING: Error converting quantity '{q}'")
quantity = q
# Try to "guess" the "type" of tracking entry, based on the title
title = entry.title.lower()
tracking_type = None
if 'completed build' in title:
tracking_type = StockHistoryCode.BUILD_OUTPUT_COMPLETED
elif 'removed' in title and 'item' in title:
if entry.notes.lower().startswith('split '):
tracking_type = StockHistoryCode.SPLIT_CHILD_ITEM
else:
tracking_type = StockHistoryCode.STOCK_REMOVE
# Extract the number of removed items
result = re.search("^removed ([\d\.]+) items", title)
if result:
removed = result.groups()[0]
try:
deltas['removed'] = float(removed)
# Ensure that 'quantity' is stored too in this case
deltas['quantity'] = float(q)
except:
print(f"WARNING: Error converting removed quantity '{removed}'")
else:
print(f"Could not decode '{title}'")
elif 'split from existing' in title:
tracking_type = StockHistoryCode.SPLIT_FROM_PARENT
deltas['quantity'] = float(q)
elif 'moved to' in title:
tracking_type = StockHistoryCode.STOCK_MOVE
result = re.search('^Moved to (.*)( - )*(.*) \(from.*$', entry.title)
if result:
# Legacy tracking entries recorded the location in multiple ways, because.. why not?
text = result.groups()[0]
matches = set()
for location in locations:
# Direct match for pathstring
if text == location._path:
matches.add(location)
# Direct match for name
if text == location.name:
matches.add(location)
# Match for "name - description"
compare = f"{location.name} - {location.description}"
if text == compare:
matches.add(location)
# Match for "pathstring - description"
compare = f"{location._path} - {location.description}"
if text == compare:
matches.add(location)
if len(matches) == 1:
location = list(matches)[0]
deltas['location'] = location.pk
else:
print(f"No location match: '{text}'")
break
elif 'created stock item' in title:
tracking_type = StockHistoryCode.CREATED
elif 'add serial number' in title:
tracking_type = StockHistoryCode.ASSIGNED_SERIAL
elif 'returned from customer' in title:
tracking_type = StockHistoryCode.RETURNED_FROM_CUSTOMER
elif 'counted' in title:
tracking_type = StockHistoryCode.STOCK_COUNT
elif 'added' in title:
tracking_type = StockHistoryCode.STOCK_ADD
# Extract the number of added items
result = re.search("^added ([\d\.]+) items", title)
if result:
added = result.groups()[0]
try:
deltas['added'] = float(added)
# Ensure that 'quantity' is stored too in this case
deltas['quantity'] = float(q)
except:
print(f"WARNING: Error converting added quantity '{added}'")
else:
print(f"Could not decode '{title}'")
elif 'assigned to customer' in title:
tracking_type = StockHistoryCode.SENT_TO_CUSTOMER
elif 'installed into stock item' in title:
tracking_type = StockHistoryCode.INSTALLED_INTO_ASSEMBLY
elif 'uninstalled into location' in title:
tracking_type = StockHistoryCode.REMOVED_FROM_ASSEMBLY
elif 'installed stock item' in title:
tracking_type = StockHistoryCode.INSTALLED_CHILD_ITEM
elif 'received items' in title:
tracking_type = StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER
if tracking_type is not None:
entry.tracking_type = tracking_type
updated = True
if updated:
entry.deltas = deltas
entry.save()
update_count += 1
print(f"\n==========================\nUpdated {update_count} StockItemHistory entries")
def reverse_update(apps, schema_editor):
"""
"""
pass
class Migration(migrations.Migration):
dependencies = [
('stock', '0060_auto_20210511_1713'),
]
operations = [
migrations.RunPython(update_history, reverse_code=reverse_update)
]

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2 on 2021-05-11 11:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stock', '0061_auto_20210511_0911'),
]
operations = [
migrations.AlterField(
model_name='stockitemtracking',
name='notes',
field=models.CharField(blank=True, help_text='Entry notes', max_length=512, null=True, verbose_name='Notes'),
),
migrations.AlterField(
model_name='stockitemtracking',
name='title',
field=models.CharField(blank=True, help_text='Tracking entry title', max_length=250, null=True, verbose_name='Title'),
),
]

View File

@ -0,0 +1,29 @@
# Generated by Django 3.2 on 2021-05-11 13:43
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('stock', '0062_auto_20210511_2151'),
]
operations = [
migrations.RemoveField(
model_name='stockitemtracking',
name='link',
),
migrations.RemoveField(
model_name='stockitemtracking',
name='quantity',
),
migrations.RemoveField(
model_name='stockitemtracking',
name='system',
),
migrations.RemoveField(
model_name='stockitemtracking',
name='title',
),
]

View File

@ -34,7 +34,7 @@ import common.models
import report.models import report.models
import label.models import label.models
from InvenTree.status_codes import StockStatus from InvenTree.status_codes import StockStatus, StockHistoryCode
from InvenTree.models import InvenTreeTree, InvenTreeAttachment from InvenTree.models import InvenTreeTree, InvenTreeAttachment
from InvenTree.fields import InvenTreeURLField from InvenTree.fields import InvenTreeURLField
@ -183,29 +183,61 @@ class StockItem(MPTTModel):
self.validate_unique() self.validate_unique()
self.clean() self.clean()
if not self.pk:
# StockItem has not yet been saved
add_note = True
else:
# StockItem has already been saved
add_note = False
user = kwargs.pop('user', None) user = kwargs.pop('user', None)
add_note = add_note and kwargs.pop('note', True) # If 'add_note = False' specified, then no tracking note will be added for item creation
add_note = kwargs.pop('add_note', True)
notes = kwargs.pop('notes', '')
if not self.pk:
# StockItem has not yet been saved
add_note = add_note and True
else:
# StockItem has already been saved
# Check if "interesting" fields have been changed
# (we wish to record these as historical records)
try:
old = StockItem.objects.get(pk=self.pk)
deltas = {}
# Status changed?
if not old.status == self.status:
deltas['status'] = self.status
# TODO - Other interesting changes we are interested in...
if add_note and len(deltas) > 0:
self.add_tracking_entry(
StockHistoryCode.EDITED,
user,
deltas=deltas,
notes=notes,
)
except (ValueError, StockItem.DoesNotExist):
pass
add_note = False
super(StockItem, self).save(*args, **kwargs) super(StockItem, self).save(*args, **kwargs)
if add_note: if add_note:
note = _('Created new stock item for {part}').format(part=str(self.part)) tracking_info = {
'status': self.status,
}
# This StockItem is being saved for the first time self.add_tracking_entry(
self.addTransactionNote( StockHistoryCode.CREATED,
_('Created stock item'),
user, user,
note, deltas=tracking_info,
system=True notes=notes,
location=self.location,
quantity=float(self.quantity),
) )
@property @property
@ -610,31 +642,42 @@ class StockItem(MPTTModel):
# TODO - Remove any stock item allocations from this stock item # TODO - Remove any stock item allocations from this stock item
item.addTransactionNote( item.add_tracking_entry(
_("Assigned to Customer"), StockHistoryCode.SENT_TO_CUSTOMER,
user, user,
notes=_("Manually assigned to customer {name}").format(name=customer.name), {
system=True 'customer': customer.id,
'customer_name': customer.name,
},
notes=notes,
) )
# Return the reference to the stock item # Return the reference to the stock item
return item return item
def returnFromCustomer(self, location, user=None): def returnFromCustomer(self, location, user=None, **kwargs):
""" """
Return stock item from customer, back into the specified location. Return stock item from customer, back into the specified location.
""" """
self.addTransactionNote( notes = kwargs.get('notes', '')
_("Returned from customer {name}").format(name=self.customer.name),
tracking_info = {}
if self.customer:
tracking_info['customer'] = self.customer.id
tracking_info['customer_name'] = self.customer.name
self.add_tracking_entry(
StockHistoryCode.RETURNED_FROM_CUSTOMER,
user, user,
notes=_("Returned to location {loc}").format(loc=location.name), notes=notes,
system=True deltas=tracking_info,
location=location
) )
self.customer = None self.customer = None
self.location = location self.location = location
self.sales_order = None
self.save() self.save()
@ -788,18 +831,23 @@ class StockItem(MPTTModel):
stock_item.save() stock_item.save()
# Add a transaction note to the other item # Add a transaction note to the other item
stock_item.addTransactionNote( stock_item.add_tracking_entry(
_('Installed into stock item {pk}').format(str(self.pk)), StockHistoryCode.INSTALLED_INTO_ASSEMBLY,
user, user,
notes=notes, notes=notes,
url=self.get_absolute_url() deltas={
'stockitem': self.pk,
}
) )
# Add a transaction note to this item # Add a transaction note to this item (the assembly)
self.addTransactionNote( self.add_tracking_entry(
_('Installed stock item {pk}').format(str(stock_item.pk)), StockHistoryCode.INSTALLED_CHILD_ITEM,
user, notes=notes, user,
url=stock_item.get_absolute_url() notes=notes,
deltas={
'stockitem': stock_item.pk,
}
) )
@transaction.atomic @transaction.atomic
@ -820,11 +868,25 @@ class StockItem(MPTTModel):
# TODO - Are there any other checks that need to be performed at this stage? # TODO - Are there any other checks that need to be performed at this stage?
# Add a transaction note to the parent item # Add a transaction note to the parent item
self.belongs_to.addTransactionNote( self.belongs_to.add_tracking_entry(
_("Uninstalled stock item {pk}").format(pk=str(self.pk)), StockHistoryCode.REMOVED_CHILD_ITEM,
user,
deltas={
'stockitem': self.pk,
},
notes=notes,
)
tracking_info = {
'stockitem': self.belongs_to.pk
}
self.add_tracking_entry(
StockHistoryCode.REMOVED_FROM_ASSEMBLY,
user, user,
notes=notes, notes=notes,
url=self.get_absolute_url(), deltas=tracking_info,
location=location,
) )
# Mark this stock item as *not* belonging to anyone # Mark this stock item as *not* belonging to anyone
@ -833,19 +895,6 @@ class StockItem(MPTTModel):
self.save() self.save()
if location:
url = location.get_absolute_url()
else:
url = ''
# Add a transaction note!
self.addTransactionNote(
_('Uninstalled into location {loc}').formaT(loc=str(location)),
user,
notes=notes,
url=url
)
@property @property
def children(self): def children(self):
""" Return a list of the child items which have been split from this stock item """ """ Return a list of the child items which have been split from this stock item """
@ -901,24 +950,40 @@ class StockItem(MPTTModel):
def has_tracking_info(self): def has_tracking_info(self):
return self.tracking_info_count > 0 return self.tracking_info_count > 0
def addTransactionNote(self, title, user, notes='', url='', system=True): def add_tracking_entry(self, entry_type, user, deltas={}, notes='', **kwargs):
""" Generation a stock transaction note for this item. """
Add a history tracking entry for this StockItem
Brief automated note detailing a movement or quantity change. Args:
entry_type - Integer code describing the "type" of historical action (see StockHistoryCode)
user - The user performing this action
deltas - A map of the changes made to the model
notes - User notes associated with this tracking entry
url - Optional URL associated with this tracking entry
""" """
track = StockItemTracking.objects.create( # Has a location been specified?
location = kwargs.get('location', None)
if location:
deltas['location'] = location.id
# Quantity specified?
quantity = kwargs.get('quantity', None)
if quantity:
deltas['quantity'] = float(quantity)
entry = StockItemTracking.objects.create(
item=self, item=self,
title=title, tracking_type=entry_type,
user=user, user=user,
quantity=self.quantity, date=datetime.now(),
date=datetime.now().date(),
notes=notes, notes=notes,
link=url, deltas=deltas,
system=system
) )
track.save() entry.save()
@transaction.atomic @transaction.atomic
def serializeStock(self, quantity, serials, user, notes='', location=None): def serializeStock(self, quantity, serials, user, notes='', location=None):
@ -930,7 +995,7 @@ class StockItem(MPTTModel):
Args: Args:
quantity: Number of items to serialize (integer) quantity: Number of items to serialize (integer)
serials: List of serial numbers (list<int>) serials: List of serial numbers
user: User object associated with action user: User object associated with action
notes: Optional notes for tracking notes: Optional notes for tracking
location: If specified, serialized items will be placed in the given location location: If specified, serialized items will be placed in the given location
@ -982,7 +1047,7 @@ class StockItem(MPTTModel):
new_item.location = location new_item.location = location
# The item already has a transaction history, don't create a new note # The item already has a transaction history, don't create a new note
new_item.save(user=user, note=False) new_item.save(user=user, notes=notes)
# Copy entire transaction history # Copy entire transaction history
new_item.copyHistoryFrom(self) new_item.copyHistoryFrom(self)
@ -991,10 +1056,18 @@ class StockItem(MPTTModel):
new_item.copyTestResultsFrom(self) new_item.copyTestResultsFrom(self)
# Create a new stock tracking item # Create a new stock tracking item
new_item.addTransactionNote(_('Add serial number'), user, notes=notes) new_item.add_tracking_entry(
StockHistoryCode.ASSIGNED_SERIAL,
user,
notes=notes,
deltas={
'serial': serial,
},
location=location
)
# Remove the equivalent number of items # Remove the equivalent number of items
self.take_stock(quantity, user, notes=_('Serialized {n} items').format(n=quantity)) self.take_stock(quantity, user, notes=notes)
@transaction.atomic @transaction.atomic
def copyHistoryFrom(self, other): def copyHistoryFrom(self, other):
@ -1018,7 +1091,7 @@ class StockItem(MPTTModel):
result.save() result.save()
@transaction.atomic @transaction.atomic
def splitStock(self, quantity, location, user): def splitStock(self, quantity, location, user, **kwargs):
""" Split this stock item into two items, in the same location. """ Split this stock item into two items, in the same location.
Stock tracking notes for this StockItem will be duplicated, Stock tracking notes for this StockItem will be duplicated,
and added to the new StockItem. and added to the new StockItem.
@ -1032,6 +1105,8 @@ class StockItem(MPTTModel):
The new item will have a different StockItem ID, while this will remain the same. The new item will have a different StockItem ID, while this will remain the same.
""" """
notes = kwargs.get('notes', '')
# Do not split a serialized part # Do not split a serialized part
if self.serialized: if self.serialized:
return self return self
@ -1071,17 +1146,21 @@ class StockItem(MPTTModel):
new_stock.copyTestResultsFrom(self) new_stock.copyTestResultsFrom(self)
# Add a new tracking item for the new stock item # Add a new tracking item for the new stock item
new_stock.addTransactionNote( new_stock.add_tracking_entry(
_("Split from existing stock"), StockHistoryCode.SPLIT_FROM_PARENT,
user, user,
_('Split {n} items').format(n=helpers.normalize(quantity)) notes=notes,
deltas={
'stockitem': self.pk,
},
location=location,
) )
# Remove the specified quantity from THIS stock item # Remove the specified quantity from THIS stock item
self.take_stock( self.take_stock(
quantity, quantity,
user, user,
f"{_('Split')} {quantity} {_('items into new stock item')}" notes=notes
) )
# Return a copy of the "new" stock item # Return a copy of the "new" stock item
@ -1131,18 +1210,17 @@ class StockItem(MPTTModel):
return True return True
if self.location:
msg = _("Moved to {loc_new} (from {loc_old})").format(loc_new=str(location), loc_old=str(self.location))
else:
msg = _('Moved to {loc_new}').format(loc_new=str(location))
self.location = location self.location = location
self.addTransactionNote( tracking_info = {}
msg,
self.add_tracking_entry(
StockHistoryCode.STOCK_MOVE,
user, user,
notes=notes, notes=notes,
system=True) deltas=tracking_info,
location=location,
)
self.save() self.save()
@ -1202,13 +1280,13 @@ class StockItem(MPTTModel):
if self.updateQuantity(count): if self.updateQuantity(count):
text = _('Counted {n} items').format(n=helpers.normalize(count)) self.add_tracking_entry(
StockHistoryCode.STOCK_COUNT,
self.addTransactionNote(
text,
user, user,
notes=notes, notes=notes,
system=True deltas={
'quantity': float(self.quantity),
}
) )
return True return True
@ -1234,20 +1312,23 @@ class StockItem(MPTTModel):
return False return False
if self.updateQuantity(self.quantity + quantity): if self.updateQuantity(self.quantity + quantity):
text = _('Added {n} items').format(n=helpers.normalize(quantity))
self.addTransactionNote( self.add_tracking_entry(
text, StockHistoryCode.STOCK_ADD,
user, user,
notes=notes, notes=notes,
system=True deltas={
'added': float(quantity),
'quantity': float(self.quantity),
}
) )
return True return True
@transaction.atomic @transaction.atomic
def take_stock(self, quantity, user, notes=''): def take_stock(self, quantity, user, notes=''):
""" Remove items from stock """
Remove items from stock
""" """
# Cannot remove items from a serialized part # Cannot remove items from a serialized part
@ -1264,12 +1345,15 @@ class StockItem(MPTTModel):
if self.updateQuantity(self.quantity - quantity): if self.updateQuantity(self.quantity - quantity):
text = _('Removed {n1} items').format(n1=helpers.normalize(quantity)) self.add_tracking_entry(
StockHistoryCode.STOCK_REMOVE,
self.addTransactionNote(text, user,
user, notes=notes,
notes=notes, deltas={
system=True) 'removed': float(quantity),
'quantity': float(self.quantity),
}
)
return True return True
@ -1527,44 +1611,58 @@ class StockItemAttachment(InvenTreeAttachment):
class StockItemTracking(models.Model): class StockItemTracking(models.Model):
""" Stock tracking entry - breacrumb for keeping track of automated stock transactions """
Stock tracking entry - used for tracking history of a particular StockItem
Note: 2021-05-11
The legacy StockTrackingItem model contained very litle information about the "history" of the item.
In fact, only the "quantity" of the item was recorded at each interaction.
Also, the "title" was translated at time of generation, and thus was not really translateable.
The "new" system tracks all 'delta' changes to the model,
and tracks change "type" which can then later be translated
Attributes: Attributes:
item: Link to StockItem item: ForeignKey reference to a particular StockItem
date: Date that this tracking info was created date: Date that this tracking info was created
title: Title of this tracking info (generated by system) tracking_type: The type of tracking information
notes: Associated notes (input by user) notes: Associated notes (input by user)
link: Optional URL to external page
user: The user associated with this tracking info user: The user associated with this tracking info
quantity: The StockItem quantity at this point in time deltas: The changes associated with this history item
""" """
def get_absolute_url(self): def get_absolute_url(self):
return '/stock/track/{pk}'.format(pk=self.id) return '/stock/track/{pk}'.format(pk=self.id)
# return reverse('stock-tracking-detail', kwargs={'pk': self.id})
item = models.ForeignKey(StockItem, on_delete=models.CASCADE, def label(self):
related_name='tracking_info')
if self.tracking_type in StockHistoryCode.keys():
return StockHistoryCode.label(self.tracking_type)
else:
return self.title
tracking_type = models.IntegerField(
default=StockHistoryCode.LEGACY,
)
item = models.ForeignKey(
StockItem,
on_delete=models.CASCADE,
related_name='tracking_info'
)
date = models.DateTimeField(auto_now_add=True, editable=False) date = models.DateTimeField(auto_now_add=True, editable=False)
title = models.CharField(blank=False, max_length=250, verbose_name=_('Title'), help_text=_('Tracking entry title')) notes = models.CharField(
blank=True, null=True,
notes = models.CharField(blank=True, max_length=512, verbose_name=_('Notes'), help_text=_('Entry notes')) max_length=512,
verbose_name=_('Notes'),
link = InvenTreeURLField(blank=True, verbose_name=_('Link'), help_text=_('Link to external page for further information')) help_text=_('Entry notes')
)
user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True) user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True)
system = models.BooleanField(default=False) deltas = models.JSONField(null=True, blank=True)
quantity = models.DecimalField(max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], default=1, verbose_name=_('Quantity'))
# TODO
# image = models.ImageField(upload_to=func, max_length=255, null=True, blank=True)
# TODO
# file = models.FileField()
def rename_stock_item_test_result_attachment(instance, filename): def rename_stock_item_test_result_attachment(instance, filename):

View File

@ -349,32 +349,32 @@ class StockTrackingSerializer(InvenTreeModelSerializer):
if user_detail is not True: if user_detail is not True:
self.fields.pop('user_detail') self.fields.pop('user_detail')
url = serializers.CharField(source='get_absolute_url', read_only=True) label = serializers.CharField(read_only=True)
item_detail = StockItemSerializerBrief(source='item', many=False, read_only=True) item_detail = StockItemSerializerBrief(source='item', many=False, read_only=True)
user_detail = UserSerializerBrief(source='user', many=False, read_only=True) user_detail = UserSerializerBrief(source='user', many=False, read_only=True)
deltas = serializers.JSONField(read_only=True)
class Meta: class Meta:
model = StockItemTracking model = StockItemTracking
fields = [ fields = [
'pk', 'pk',
'url',
'item', 'item',
'item_detail', 'item_detail',
'date', 'date',
'title', 'deltas',
'label',
'notes', 'notes',
'link', 'tracking_type',
'quantity',
'user', 'user',
'user_detail', 'user_detail',
'system',
] ]
read_only_fields = [ read_only_fields = [
'date', 'date',
'user', 'user',
'system', 'label',
'quantity', 'tracking_type',
] ]

View File

@ -94,7 +94,13 @@
{% if item.is_expired %} {% if item.is_expired %}
<span class='label label-large label-large-red'>{% trans "Expired" %}</span> <span class='label label-large label-large-red'>{% trans "Expired" %}</span>
{% else %} {% else %}
{% if roles.stock.change %}
<a href='#' id='stock-edit-status'>
{% endif %}
{% stock_status_label item.status large=True %} {% stock_status_label item.status large=True %}
{% if roles.stock.change %}
</a>
{% endif %}
{% if item.is_stale %} {% if item.is_stale %}
<span class='label label-large label-large-yellow'>{% trans "Stale" %}</span> <span class='label label-large label-large-yellow'>{% trans "Stale" %}</span>
{% endif %} {% endif %}
@ -453,6 +459,7 @@ $("#print-label").click(function() {
printStockItemLabels([{{ item.pk }}]); printStockItemLabels([{{ item.pk }}]);
}); });
{% if roles.stock.change %}
$("#stock-duplicate").click(function() { $("#stock-duplicate").click(function() {
createNewStockItem({ createNewStockItem({
follow: true, follow: true,
@ -472,6 +479,18 @@ $("#stock-edit").click(function () {
); );
}); });
$('#stock-edit-status').click(function () {
launchModalForm(
"{% url 'stock-item-edit-status' item.id %}",
{
reload: true,
submit_text: '{% trans "Save" %}',
}
);
});
{% endif %}
$("#show-qr-code").click(function() { $("#show-qr-code").click(function() {
launchModalForm("{% url 'stock-item-qr' item.id %}", launchModalForm("{% url 'stock-item-qr' item.id %}",
{ {

View File

@ -5,6 +5,8 @@ from django.core.exceptions import ValidationError
import datetime import datetime
from InvenTree.status_codes import StockHistoryCode
from .models import StockLocation, StockItem, StockItemTracking from .models import StockLocation, StockItem, StockItemTracking
from .models import StockItemTestResult from .models import StockItemTestResult
@ -217,7 +219,7 @@ class StockTest(TestCase):
track = StockItemTracking.objects.filter(item=it).latest('id') track = StockItemTracking.objects.filter(item=it).latest('id')
self.assertEqual(track.item, it) self.assertEqual(track.item, it)
self.assertIn('Moved to', track.title) self.assertEqual(track.tracking_type, StockHistoryCode.STOCK_MOVE)
self.assertEqual(track.notes, 'Moved to the bathroom') self.assertEqual(track.notes, 'Moved to the bathroom')
def test_self_move(self): def test_self_move(self):
@ -284,8 +286,7 @@ class StockTest(TestCase):
# Check that a tracking item was added # Check that a tracking item was added
track = StockItemTracking.objects.filter(item=it).latest('id') track = StockItemTracking.objects.filter(item=it).latest('id')
self.assertIn('Counted', track.title) self.assertEqual(track.tracking_type, StockHistoryCode.STOCK_COUNT)
self.assertIn('items', track.title)
self.assertIn('Counted items', track.notes) self.assertIn('Counted items', track.notes)
n = it.tracking_info.count() n = it.tracking_info.count()
@ -304,7 +305,7 @@ class StockTest(TestCase):
# Check that a tracking item was added # Check that a tracking item was added
track = StockItemTracking.objects.filter(item=it).latest('id') track = StockItemTracking.objects.filter(item=it).latest('id')
self.assertIn('Added', track.title) self.assertEqual(track.tracking_type, StockHistoryCode.STOCK_ADD)
self.assertIn('Added some items', track.notes) self.assertIn('Added some items', track.notes)
self.assertFalse(it.add_stock(-10, None)) self.assertFalse(it.add_stock(-10, None))
@ -319,7 +320,7 @@ class StockTest(TestCase):
# Check that a tracking item was added # Check that a tracking item was added
track = StockItemTracking.objects.filter(item=it).latest('id') track = StockItemTracking.objects.filter(item=it).latest('id')
self.assertIn('Removed', track.title) self.assertEqual(track.tracking_type, StockHistoryCode.STOCK_REMOVE)
self.assertIn('Removed some items', track.notes) self.assertIn('Removed some items', track.notes)
self.assertTrue(it.has_tracking_info) self.assertTrue(it.has_tracking_info)

View File

@ -4,7 +4,7 @@ URL lookup for Stock app
from django.conf.urls import url, include from django.conf.urls import url, include
from . import views from stock import views
location_urls = [ location_urls = [
@ -24,6 +24,7 @@ location_urls = [
] ]
stock_item_detail_urls = [ stock_item_detail_urls = [
url(r'^edit_status/', views.StockItemEditStatus.as_view(), name='stock-item-edit-status'),
url(r'^edit/', views.StockItemEdit.as_view(), name='stock-item-edit'), url(r'^edit/', views.StockItemEdit.as_view(), name='stock-item-edit'),
url(r'^convert/', views.StockItemConvert.as_view(), name='stock-item-convert'), url(r'^convert/', views.StockItemConvert.as_view(), name='stock-item-convert'),
url(r'^serialize/', views.StockItemSerialize.as_view(), name='stock-item-serialize'), url(r'^serialize/', views.StockItemSerialize.as_view(), name='stock-item-serialize'),

View File

@ -1212,6 +1212,27 @@ class StockAdjust(AjaxView, FormMixin):
return _("Deleted {n} stock items").format(n=count) return _("Deleted {n} stock items").format(n=count)
class StockItemEditStatus(AjaxUpdateView):
"""
View for editing stock item status field
"""
model = StockItem
form_class = StockForms.EditStockItemStatusForm
ajax_form_title = _('Edit Stock Item Status')
def save(self, object, form, **kwargs):
"""
Override the save method, to track the user who updated the model
"""
item = form.save(commit=False)
item.save(user=self.request.user)
return item
class StockItemEdit(AjaxUpdateView): class StockItemEdit(AjaxUpdateView):
""" """
View for editing details of a single StockItem View for editing details of a single StockItem
@ -1321,6 +1342,17 @@ class StockItemEdit(AjaxUpdateView):
if not owner and not self.request.user.is_superuser: if not owner and not self.request.user.is_superuser:
form.add_error('owner', _('Owner is required (ownership control is enabled)')) form.add_error('owner', _('Owner is required (ownership control is enabled)'))
def save(self, object, form, **kwargs):
"""
Override the save method, to track the user who updated the model
"""
item = form.save(commit=False)
item.save(user=self.request.user)
return item
class StockItemConvert(AjaxUpdateView): class StockItemConvert(AjaxUpdateView):
""" """

View File

@ -976,42 +976,28 @@ function loadStockLocationTable(table, options) {
function loadStockTrackingTable(table, options) { function loadStockTrackingTable(table, options) {
var cols = [ var cols = [];
{
field: 'pk',
visible: false,
},
{
field: 'date',
title: '{% trans "Date" %}',
sortable: true,
formatter: function(value, row, index, field) {
var m = moment(value);
if (m.isValid()) {
var html = m.format('dddd MMMM Do YYYY'); // + '<br>' + m.format('h:mm a');
return html;
}
return 'N/A'; // Date
} cols.push({
}, field: 'date',
]; title: '{% trans "Date" %}',
sortable: true,
formatter: function(value, row, index, field) {
var m = moment(value);
// If enabled, provide a link to the referenced StockItem if (m.isValid()) {
if (options.partColumn) { var html = m.format('dddd MMMM Do YYYY'); // + '<br>' + m.format('h:mm a');
cols.push({ return html;
field: 'item',
title: '{% trans "Stock Item" %}',
sortable: true,
formatter: function(value, row, index, field) {
return renderLink(value.part_name, value.url);
} }
});
} return '<i>{% trans "Invalid date" %}</i>';
}
});
// Stock transaction description // Stock transaction description
cols.push({ cols.push({
field: 'title', field: 'label',
title: '{% trans "Description" %}', title: '{% trans "Description" %}',
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {
var html = "<b>" + value + "</b>"; var html = "<b>" + value + "</b>";
@ -1020,20 +1006,139 @@ function loadStockTrackingTable(table, options) {
html += "<br><i>" + row.notes + "</i>"; html += "<br><i>" + row.notes + "</i>";
} }
if (row.link) {
html += "<br><a href='" + row.link + "'>" + row.link + "</a>";
}
return html; return html;
} }
}); });
// Stock transaction details
cols.push({ cols.push({
field: 'quantity', field: 'deltas',
title: '{% trans "Quantity" %}', title: '{% trans "Details" %}',
formatter: function(value, row, index, field) { formatter: function(details, row, index, field) {
return parseFloat(value); var html = `<table class='table table-condensed' id='tracking-table-${row.pk}'>`;
},
// Location information
if (details.location) {
html += `<tr><th>{% trans "Location" %}</th>`;
html += '<td>';
if (details.location_detail) {
// A valid location is provided
html += renderLink(
details.location_detail.pathstring,
details.location_detail.url,
);
} else {
// An invalid location (may have been deleted?)
html += `<i>{% trans "Location no longer exists" %}</i>`;
}
html += '</td></tr>';
}
// Purchase Order Information
if (details.purchaseorder) {
html += `<tr><th>{% trans "Purchase Order" %}</td>`;
html += '<td>';
if (details.purchaseorder_detail) {
html += renderLink(
details.purchaseorder_detail.reference,
`/order/purchase-order/${details.purchaseorder}/`
);
} else {
html += `<i>{% trans "Purchase order no longer exists" %}</i>`;
}
html += '</td></tr>';
}
// Customer information
if (details.customer) {
html += `<tr><th>{% trans "Customer" %}</td>`;
html += '<td>';
if (details.customer_detail) {
html += renderLink(
details.customer_detail.name,
details.customer_detail.url
);
} else {
html += `<i>{% trans "Customer no longer exists" %}</i>`;
}
html += '</td></tr>';
}
// Stockitem information
if (details.stockitem) {
html += '<tr><th>{% trans "Stock Item" %}</td>';
html += '<td>';
if (details.stockitem_detail) {
html += renderLink(
details.stockitem,
`/stock/item/${details.stockitem}/`
);
} else {
html += `<i>{% trans "Stock item no longer exists" %}</i>`;
}
html += '</td></tr>';
}
// Status information
if (details.status) {
html += `<tr><th>{% trans "Status" %}</td>`;
html += '<td>';
html += stockStatusDisplay(
details.status,
{
classes: 'float-right',
}
);
html += '</td></tr>';
}
// Quantity information
if (details.added) {
html += '<tr><th>{% trans "Added" %}</th>';
html += `<td>${details.added}</td>`;
html += '</tr>';
}
if (details.removed) {
html += '<tr><th>{% trans "Removed" %}</th>';
html += `<td>${details.removed}</td>`;
html += '</tr>';
}
if (details.quantity) {
html += '<tr><th>{% trans "Quantity" %}</th>';
html += `<td>${details.quantity}</td>`;
html += '</tr>';
}
html += '</table>';
return html;
}
}); });
cols.push({ cols.push({
@ -1052,11 +1157,13 @@ function loadStockTrackingTable(table, options) {
} }
}); });
/*
// 2021-05-11 - Ability to edit or delete StockItemTracking entries is now removed
cols.push({ cols.push({
sortable: false, sortable: false,
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {
// Manually created entries can be edited or deleted // Manually created entries can be edited or deleted
if (!row.system) { if (false && !row.system) {
var bEdit = "<button title='{% trans 'Edit tracking entry' %}' class='btn btn-entry-edit btn-default btn-glyph' type='button' url='/stock/track/" + row.pk + "/edit/'><span class='fas fa-edit'/></button>"; var bEdit = "<button title='{% trans 'Edit tracking entry' %}' class='btn btn-entry-edit btn-default btn-glyph' type='button' url='/stock/track/" + row.pk + "/edit/'><span class='fas fa-edit'/></button>";
var bDel = "<button title='{% trans 'Delete tracking entry' %}' class='btn btn-entry-delete btn-default btn-glyph' type='button' url='/stock/track/" + row.pk + "/delete/'><span class='fas fa-trash-alt icon-red'/></button>"; var bDel = "<button title='{% trans 'Delete tracking entry' %}' class='btn btn-entry-delete btn-default btn-glyph' type='button' url='/stock/track/" + row.pk + "/delete/'><span class='fas fa-trash-alt icon-red'/></button>";
@ -1066,6 +1173,7 @@ function loadStockTrackingTable(table, options) {
} }
} }
}); });
*/
table.inventreeTable({ table.inventreeTable({
method: 'get', method: 'get',

View File

@ -3,6 +3,7 @@
{% load inventree_extras %} {% load inventree_extras %}
{% include "status_codes.html" with label='stock' options=StockStatus.list %} {% include "status_codes.html" with label='stock' options=StockStatus.list %}
{% include "status_codes.html" with label='stockHistory' options=StockHistoryCode.list %}
{% include "status_codes.html" with label='build' options=BuildStatus.list %} {% include "status_codes.html" with label='build' options=BuildStatus.list %}
{% include "status_codes.html" with label='purchaseOrder' options=PurchaseOrderStatus.list %} {% include "status_codes.html" with label='purchaseOrder' options=PurchaseOrderStatus.list %}
{% include "status_codes.html" with label='salesOrder' options=SalesOrderStatus.list %} {% include "status_codes.html" with label='salesOrder' options=SalesOrderStatus.list %}

View File

@ -14,7 +14,7 @@ var {{ label }}Codes = {
* Uses the values specified in "status_codes.py" * Uses the values specified in "status_codes.py"
* This function is generated by the "status_codes.html" template * This function is generated by the "status_codes.html" template
*/ */
function {{ label }}StatusDisplay(key) { function {{ label }}StatusDisplay(key, options={}) {
key = String(key); key = String(key);
@ -31,5 +31,11 @@ function {{ label }}StatusDisplay(key) {
label = ''; label = '';
} }
return `<span class='label ${label}'>${value}</span>`; var classes = `label ${label}`;
if (options.classes) {
classes += ' ' + options.classes;
}
return `<span class='${classes}'>${value}</span>`;
} }

View File

@ -1,4 +1,4 @@
FROM python:alpine as production FROM python:alpine as base
# GitHub source # GitHub source
ARG repository="https://github.com/inventree/InvenTree.git" ARG repository="https://github.com/inventree/InvenTree.git"
@ -73,6 +73,7 @@ RUN pip install --no-cache-dir -U invoke
RUN pip install --no-cache-dir -U psycopg2 mysqlclient pgcli mariadb RUN pip install --no-cache-dir -U psycopg2 mysqlclient pgcli mariadb
RUN pip install --no-cache-dir -U gunicorn RUN pip install --no-cache-dir -U gunicorn
FROM base as production
# Clone source code # Clone source code
RUN echo "Downloading InvenTree from ${INVENTREE_REPO}" RUN echo "Downloading InvenTree from ${INVENTREE_REPO}"
RUN git clone --branch ${INVENTREE_BRANCH} --depth 1 ${INVENTREE_REPO} ${INVENTREE_SRC_DIR} RUN git clone --branch ${INVENTREE_BRANCH} --depth 1 ${INVENTREE_REPO} ${INVENTREE_SRC_DIR}
@ -85,11 +86,9 @@ COPY gunicorn.conf.py ${INVENTREE_HOME}/gunicorn.conf.py
# Copy startup scripts # Copy startup scripts
COPY start_prod_server.sh ${INVENTREE_SRC_DIR}/start_prod_server.sh COPY start_prod_server.sh ${INVENTREE_SRC_DIR}/start_prod_server.sh
COPY start_dev_server.sh ${INVENTREE_SRC_DIR}/start_dev_server.sh
COPY start_worker.sh ${INVENTREE_SRC_DIR}/start_worker.sh COPY start_worker.sh ${INVENTREE_SRC_DIR}/start_worker.sh
RUN chmod 755 ${INVENTREE_SRC_DIR}/start_prod_server.sh RUN chmod 755 ${INVENTREE_SRC_DIR}/start_prod_server.sh
RUN chmod 755 ${INVENTREE_SRC_DIR}/start_dev_server.sh
RUN chmod 755 ${INVENTREE_SRC_DIR}/start_worker.sh RUN chmod 755 ${INVENTREE_SRC_DIR}/start_worker.sh
# exec commands should be executed from the "src" directory # exec commands should be executed from the "src" directory
@ -97,3 +96,17 @@ WORKDIR ${INVENTREE_SRC_DIR}
# Let us begin # Let us begin
CMD ["bash", "./start_prod_server.sh"] CMD ["bash", "./start_prod_server.sh"]
FROM base as dev
# The development image requires the source code to be mounted to /home/inventree/src/
# So from here, we don't actually "do" anything
WORKDIR ${INVENTREE_SRC_DIR}
COPY start_dev_server.sh ${INVENTREE_HOME}/start_dev_server.sh
COPY start_dev_worker.sh ${INVENTREE_HOME}/start_dev_worker.sh
RUN chmod 755 ${INVENTREE_HOME}/start_dev_server.sh
RUN chmod 755 ${INVENTREE_HOME}/start_dev_worker.sh
CMD ["bash", "/home/inventree/start_dev_server.sh"]

7
docker/dev-config.env Normal file
View File

@ -0,0 +1,7 @@
INVENTREE_DB_ENGINE=sqlite3
INVENTREE_DB_NAME=/home/inventree/src/inventree_docker_dev.sqlite3
INVENTREE_MEDIA_ROOT=/home/inventree/src/inventree_media
INVENTREE_STATIC_ROOT=/home/inventree/src/inventree_static
INVENTREE_CONFIG_FILE=/home/inventree/src/config.yaml
INVENTREE_SECRET_KEY_FILE=/home/inventree/src/secret_key.txt
INVENTREE_DEBUG=true

View File

@ -0,0 +1,59 @@
version: "3.8"
# Docker compose recipe for InvenTree development server
# - Runs sqlite3 as the database backend
# - Uses built-in django webserver
# IMPORANT NOTE:
# The InvenTree docker image does not clone source code from git.
# Instead, you must specify *where* the source code is located,
# (on your local machine).
# The django server will auto-detect any code changes and reload the server.
services:
# InvenTree web server services
# Uses gunicorn as the web server
inventree-server:
container_name: inventree-server
build:
context: .
target: dev
ports:
- 8000:8000
volumes:
# Ensure you specify the location of the 'src' directory at the end of this file
- src:/home/inventree/src
env_file:
# Environment variables required for the dev server are configured in dev-config.env
- dev-config.env
restart: unless-stopped
# Background worker process handles long-running or periodic tasks
inventree-worker:
container_name: inventree-worker
build:
context: .
target: dev
entrypoint: /home/inventree/start_dev_worker.sh
depends_on:
- inventree-server
volumes:
# Ensure you specify the location of the 'src' directory at the end of this file
- src:/home/inventree/src
env_file:
# Environment variables required for the dev server are configured in dev-config.env
- dev-config.env
restart: unless-stopped
volumes:
# NOTE: Change /path/to/src to a directory on your local machine, where the InvenTree source code is located
# Persistent data, stored external to the container(s)
src:
driver: local
driver_opts:
type: none
o: bind
# This directory specified where InvenTree source code is stored "outside" the docker containers
# Note: This directory must conatin the file *manage.py*
device: /path/to/inventree/src

View File

@ -19,6 +19,14 @@ else
cp $INVENTREE_SRC_DIR/InvenTree/config_template.yaml $INVENTREE_CONFIG_FILE cp $INVENTREE_SRC_DIR/InvenTree/config_template.yaml $INVENTREE_CONFIG_FILE
fi fi
# Setup a virtual environment
python3 -m venv inventree-docker-dev
source inventree-docker-dev/bin/activate
echo "Installing required packages..."
pip install --no-cache-dir -U -r ${INVENTREE_SRC_DIR}/requirements.txt
echo "Starting InvenTree server..." echo "Starting InvenTree server..."
# Wait for the database to be ready # Wait for the database to be ready
@ -27,16 +35,14 @@ python manage.py wait_for_db
sleep 10 sleep 10
echo "Running InvenTree database migrations and collecting static files..." echo "Running InvenTree database migrations..."
# We assume at this stage that the database is up and running # We assume at this stage that the database is up and running
# Ensure that the database schema are up to date # Ensure that the database schema are up to date
python manage.py check || exit 1 python manage.py check || exit 1
python manage.py migrate --noinput || exit 1 python manage.py migrate --noinput || exit 1
python manage.py migrate --run-syncdb || exit 1 python manage.py migrate --run-syncdb || exit 1
python manage.py prerender || exit 1
python manage.py collectstatic --noinput || exit 1
python manage.py clearsessions || exit 1 python manage.py clearsessions || exit 1
# Launch a development server # Launch a development server
python manage.py runserver -a 0.0.0.0:$INVENTREE_WEB_PORT python manage.py runserver 0.0.0.0:$INVENTREE_WEB_PORT

View File

@ -0,0 +1,19 @@
#!/bin/sh
echo "Starting InvenTree worker..."
cd $INVENTREE_SRC_DIR
# Activate virtual environment
source inventree-docker-dev/bin/activate
sleep 5
# Wait for the database to be ready
cd $INVENTREE_MNG_DIR
python manage.py wait_for_db
sleep 10
# Now we can launch the background worker process
python manage.py qcluster

View File

@ -11,9 +11,10 @@ django-markdownx==3.0.1 # Markdown form fields
django-markdownify==0.8.0 # Markdown rendering django-markdownify==0.8.0 # Markdown rendering
coreapi==2.3.0 # API documentation coreapi==2.3.0 # API documentation
pygments==2.7.4 # Syntax highlighting pygments==2.7.4 # Syntax highlighting
tablib==0.13.0 # Import / export data files # tablib==0.13.0 # Import / export data files (installed as dependency of django-import-export package)
django-crispy-forms==1.11.2 # Form helpers django-crispy-forms==1.11.2 # Form helpers
django-import-export==2.0.0 # Data import / export for admin interface django-import-export==2.0.0 # Data import / export for admin interface
tablib[xls,xlsx,yaml] # Support for XLS and XLSX formats
django-cleanup==5.1.0 # Manage deletion of old / unused uploaded files django-cleanup==5.1.0 # Manage deletion of old / unused uploaded files
flake8==3.8.3 # PEP checking flake8==3.8.3 # PEP checking
pep8-naming==0.11.1 # PEP naming convention extension pep8-naming==0.11.1 # PEP naming convention extension
@ -32,5 +33,6 @@ python-barcode[images]==0.13.1 # Barcode generator
qrcode[pil]==6.1 # QR code generator qrcode[pil]==6.1 # QR code generator
django-q==1.3.4 # Background task scheduling django-q==1.3.4 # Background task scheduling
gunicorn>=20.0.4 # Gunicorn web server gunicorn>=20.0.4 # Gunicorn web server
django-formtools==2.3 # Form wizard tools
inventree # Install the latest version of the InvenTree API python library inventree # Install the latest version of the InvenTree API python library