mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'inventree:master' into price-history
This commit is contained in:
commit
b1410c7c2b
1
.github/workflows/docker_build.yaml
vendored
1
.github/workflows/docker_build.yaml
vendored
@ -30,6 +30,7 @@ jobs:
|
||||
context: ./docker
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
push: true
|
||||
target: production
|
||||
repository: inventree/inventree
|
||||
tags: inventree/inventree:latest
|
||||
- name: Image Digest
|
||||
|
1
.github/workflows/docker_publish.yaml
vendored
1
.github/workflows/docker_publish.yaml
vendored
@ -28,4 +28,5 @@ jobs:
|
||||
repository: inventree/inventree
|
||||
tag_with_ref: true
|
||||
dockerfile: ./Dockerfile
|
||||
target: production
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -31,6 +31,7 @@ var/
|
||||
*.log
|
||||
local_settings.py
|
||||
*.sqlite3
|
||||
*.sqlite3-journal
|
||||
*.backup
|
||||
*.old
|
||||
|
||||
|
@ -6,6 +6,7 @@ Provides extra global data to all templates.
|
||||
|
||||
from InvenTree.status_codes import SalesOrderStatus, PurchaseOrderStatus
|
||||
from InvenTree.status_codes import BuildStatus, StockStatus
|
||||
from InvenTree.status_codes import StockHistoryCode
|
||||
|
||||
import InvenTree.status
|
||||
|
||||
@ -65,6 +66,7 @@ def status_codes(request):
|
||||
'PurchaseOrderStatus': PurchaseOrderStatus,
|
||||
'BuildStatus': BuildStatus,
|
||||
'StockStatus': StockStatus,
|
||||
'StockHistoryCode': StockHistoryCode,
|
||||
}
|
||||
|
||||
|
||||
|
@ -263,6 +263,7 @@ INSTALLED_APPS = [
|
||||
'djmoney.contrib.exchange', # django-money exchange rates
|
||||
'error_report', # Error reporting in the admin interface
|
||||
'django_q',
|
||||
'formtools', # Form wizard tools
|
||||
]
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
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
|
||||
db_engine = f'django.db.backends.{db_engine.lower()}'
|
||||
db_engine = f'django.db.backends.{db_engine}'
|
||||
db_config['ENGINE'] = db_engine
|
||||
|
||||
db_name = db_config['NAME']
|
||||
|
@ -7,6 +7,8 @@ class StatusCode:
|
||||
This is used to map a set of integer values to text.
|
||||
"""
|
||||
|
||||
colors = {}
|
||||
|
||||
@classmethod
|
||||
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):
|
||||
|
||||
# Build status codes
|
||||
|
@ -22,7 +22,7 @@ from markdownx.models import MarkdownxField
|
||||
|
||||
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.validators import validate_build_order_reference
|
||||
from InvenTree.models import InvenTreeAttachment
|
||||
@ -811,6 +811,7 @@ class Build(MPTTModel):
|
||||
# Select the location for the build output
|
||||
location = kwargs.get('location', self.destination)
|
||||
status = kwargs.get('status', StockStatus.OK)
|
||||
notes = kwargs.get('notes', '')
|
||||
|
||||
# List the allocated BuildItem objects for the given output
|
||||
allocated_items = output.items_to_install.all()
|
||||
@ -834,10 +835,13 @@ class Build(MPTTModel):
|
||||
|
||||
output.save()
|
||||
|
||||
output.addTransactionNote(
|
||||
_('Completed build output'),
|
||||
output.add_tracking_entry(
|
||||
StockHistoryCode.BUILD_OUTPUT_COMPLETED,
|
||||
user,
|
||||
system=True
|
||||
notes=notes,
|
||||
deltas={
|
||||
'status': status,
|
||||
}
|
||||
)
|
||||
|
||||
# Increase the completed quantity for this build
|
||||
|
240
InvenTree/common/files.py
Normal file
240
InvenTree/common/files.py
Normal 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]
|
@ -5,8 +5,16 @@ Django forms for interacting with common objects
|
||||
# -*- coding: utf-8 -*-
|
||||
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 .files import FileManager
|
||||
from .models import InvenTreeSetting
|
||||
|
||||
|
||||
@ -21,3 +29,183 @@ class SettingEditForm(HelperForm):
|
||||
fields = [
|
||||
'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,
|
||||
)
|
||||
|
@ -5,14 +5,21 @@ Django views for interacting with common models
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import os
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
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.helpers import str2bool
|
||||
|
||||
from . import models
|
||||
from . import forms
|
||||
from .files import FileManager
|
||||
|
||||
|
||||
class SettingEdit(AjaxUpdateView):
|
||||
@ -101,3 +108,392 @@ class SettingEdit(AjaxUpdateView):
|
||||
|
||||
if not str2bool(value, test=True) and not str2bool(value, test=False):
|
||||
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)
|
||||
|
@ -28,7 +28,7 @@ from company.models import Company, SupplierPart
|
||||
|
||||
from InvenTree.fields import RoundingDecimalField
|
||||
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
|
||||
|
||||
|
||||
@ -336,10 +336,12 @@ class PurchaseOrder(Order):
|
||||
return self.pending_line_items().count() == 0
|
||||
|
||||
@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
|
||||
"""
|
||||
|
||||
notes = kwargs.get('notes', '')
|
||||
|
||||
if not self.status == PurchaseOrderStatus.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,
|
||||
)
|
||||
|
||||
stock.save()
|
||||
stock.save(add_note=False)
|
||||
|
||||
text = _("Received items")
|
||||
note = _('Received {n} items against order {name}').format(n=quantity, name=str(self))
|
||||
tracking_info = {
|
||||
'status': status,
|
||||
'purchaseorder': self.pk,
|
||||
}
|
||||
|
||||
# Add a new transaction note to the newly created stock item
|
||||
stock.addTransactionNote(text, user, note)
|
||||
stock.add_tracking_entry(
|
||||
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
|
||||
line.received += quantity
|
||||
|
@ -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 %}
|
125
InvenTree/order/templates/order/order_wizard/match_parts.html
Normal file
125
InvenTree/order/templates/order/order_wizard/match_parts.html
Normal 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 %}
|
56
InvenTree/order/templates/order/order_wizard/po_upload.html
Normal file
56
InvenTree/order/templates/order/order_wizard/po_upload.html
Normal 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 %}
|
@ -1,6 +1,7 @@
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load inventree_extras %}
|
||||
{% load status_codes %}
|
||||
|
||||
<ul class='list-group'>
|
||||
<li class='list-group-item'>
|
||||
@ -14,6 +15,14 @@
|
||||
{% trans "Details" %}
|
||||
</a>
|
||||
</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" %}'>
|
||||
<a href='{% url "po-received" order.id %}'>
|
||||
<span class='fas fa-sign-in-alt'></span>
|
||||
|
@ -17,6 +17,7 @@ purchase_order_detail_urls = [
|
||||
url(r'^receive/', views.PurchaseOrderReceive.as_view(), name='po-receive'),
|
||||
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'^notes/', views.PurchaseOrderNotes.as_view(), name='po-notes'),
|
||||
|
@ -6,10 +6,12 @@ Django views for interacting with Order app
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.utils import IntegrityError
|
||||
from django.http.response import JsonResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.urls import reverse
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.views.generic import DetailView, ListView, UpdateView
|
||||
from django.views.generic.edit import FormMixin
|
||||
@ -23,11 +25,12 @@ from .models import SalesOrder, SalesOrderLineItem, SalesOrderAttachment
|
||||
from .models import SalesOrderAllocation
|
||||
from .admin import POLineItemResource
|
||||
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 part.models import Part
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
from common.views import FileManagementFormView
|
||||
|
||||
from . import forms as order_forms
|
||||
from part.views import PartPricing
|
||||
@ -566,6 +569,192 @@ class SalesOrderShip(AjaxUpdateView):
|
||||
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):
|
||||
""" File download for a purchase order
|
||||
|
||||
|
@ -130,7 +130,7 @@ class StockAttachmentAdmin(admin.ModelAdmin):
|
||||
|
||||
|
||||
class StockTrackingAdmin(ImportExportModelAdmin):
|
||||
list_display = ('item', 'date', 'title')
|
||||
list_display = ('item', 'date', 'label')
|
||||
|
||||
|
||||
class StockItemTestResultAdmin(admin.ModelAdmin):
|
||||
|
@ -21,8 +21,11 @@ from .models import StockItemTestResult
|
||||
from part.models import Part, PartCategory
|
||||
from part.serializers import PartBriefSerializer
|
||||
|
||||
from company.models import SupplierPart
|
||||
from company.serializers import SupplierPartSerializer
|
||||
from company.models import Company, SupplierPart
|
||||
from company.serializers import CompanySerializer, SupplierPartSerializer
|
||||
|
||||
from order.models import PurchaseOrder
|
||||
from order.serializers import POSerializer
|
||||
|
||||
import common.settings
|
||||
import common.models
|
||||
@ -97,6 +100,16 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
|
||||
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):
|
||||
""" FilterSet for advanced stock filtering.
|
||||
@ -371,25 +384,26 @@ class StockList(generics.ListCreateAPIView):
|
||||
we can pre-fill the location automatically.
|
||||
"""
|
||||
|
||||
user = request.user
|
||||
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# TODO - Save the user who created this item
|
||||
item = serializer.save()
|
||||
|
||||
# A location was *not* specified - try to infer it
|
||||
if 'location' not in request.data:
|
||||
location = item.part.get_default_location()
|
||||
|
||||
if location is not None:
|
||||
item.location = location
|
||||
item.save()
|
||||
item.location = item.part.get_default_location()
|
||||
|
||||
# An expiry date was *not* specified - try to infer it!
|
||||
if 'expiry_date' not in request.data:
|
||||
|
||||
if item.part.default_expiry > 0:
|
||||
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
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
@ -965,7 +979,7 @@ class StockItemTestResultList(generics.ListCreateAPIView):
|
||||
test_result.save()
|
||||
|
||||
|
||||
class StockTrackingList(generics.ListCreateAPIView):
|
||||
class StockTrackingList(generics.ListAPIView):
|
||||
""" API endpoint for list view of StockItemTracking objects.
|
||||
|
||||
StockItemTracking objects are read-only
|
||||
@ -992,6 +1006,59 @@ class StockTrackingList(generics.ListCreateAPIView):
|
||||
|
||||
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):
|
||||
""" Create a new StockItemTracking object
|
||||
|
||||
|
@ -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):
|
||||
""" Form for editing a StockItem object.
|
||||
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):
|
||||
""" 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:
|
||||
model = StockItemTracking
|
||||
|
||||
fields = [
|
||||
'title',
|
||||
'notes',
|
||||
'link',
|
||||
]
|
||||
|
28
InvenTree/stock/migrations/0060_auto_20210511_1713.py
Normal file
28
InvenTree/stock/migrations/0060_auto_20210511_1713.py
Normal 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'),
|
||||
),
|
||||
]
|
219
InvenTree/stock/migrations/0061_auto_20210511_0911.py
Normal file
219
InvenTree/stock/migrations/0061_auto_20210511_0911.py
Normal 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)
|
||||
]
|
23
InvenTree/stock/migrations/0062_auto_20210511_2151.py
Normal file
23
InvenTree/stock/migrations/0062_auto_20210511_2151.py
Normal 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'),
|
||||
),
|
||||
]
|
29
InvenTree/stock/migrations/0063_auto_20210511_2343.py
Normal file
29
InvenTree/stock/migrations/0063_auto_20210511_2343.py
Normal 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',
|
||||
),
|
||||
]
|
@ -34,7 +34,7 @@ import common.models
|
||||
import report.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.fields import InvenTreeURLField
|
||||
|
||||
@ -183,29 +183,61 @@ class StockItem(MPTTModel):
|
||||
self.validate_unique()
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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.addTransactionNote(
|
||||
_('Created stock item'),
|
||||
self.add_tracking_entry(
|
||||
StockHistoryCode.CREATED,
|
||||
user,
|
||||
note,
|
||||
system=True
|
||||
deltas=tracking_info,
|
||||
notes=notes,
|
||||
location=self.location,
|
||||
quantity=float(self.quantity),
|
||||
)
|
||||
|
||||
@property
|
||||
@ -610,31 +642,42 @@ class StockItem(MPTTModel):
|
||||
|
||||
# TODO - Remove any stock item allocations from this stock item
|
||||
|
||||
item.addTransactionNote(
|
||||
_("Assigned to Customer"),
|
||||
item.add_tracking_entry(
|
||||
StockHistoryCode.SENT_TO_CUSTOMER,
|
||||
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 item
|
||||
|
||||
def returnFromCustomer(self, location, user=None):
|
||||
def returnFromCustomer(self, location, user=None, **kwargs):
|
||||
"""
|
||||
Return stock item from customer, back into the specified location.
|
||||
"""
|
||||
|
||||
self.addTransactionNote(
|
||||
_("Returned from customer {name}").format(name=self.customer.name),
|
||||
notes = kwargs.get('notes', '')
|
||||
|
||||
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,
|
||||
notes=_("Returned to location {loc}").format(loc=location.name),
|
||||
system=True
|
||||
notes=notes,
|
||||
deltas=tracking_info,
|
||||
location=location
|
||||
)
|
||||
|
||||
self.customer = None
|
||||
self.location = location
|
||||
self.sales_order = None
|
||||
|
||||
self.save()
|
||||
|
||||
@ -788,18 +831,23 @@ class StockItem(MPTTModel):
|
||||
stock_item.save()
|
||||
|
||||
# Add a transaction note to the other item
|
||||
stock_item.addTransactionNote(
|
||||
_('Installed into stock item {pk}').format(str(self.pk)),
|
||||
stock_item.add_tracking_entry(
|
||||
StockHistoryCode.INSTALLED_INTO_ASSEMBLY,
|
||||
user,
|
||||
notes=notes,
|
||||
url=self.get_absolute_url()
|
||||
deltas={
|
||||
'stockitem': self.pk,
|
||||
}
|
||||
)
|
||||
|
||||
# Add a transaction note to this item
|
||||
self.addTransactionNote(
|
||||
_('Installed stock item {pk}').format(str(stock_item.pk)),
|
||||
user, notes=notes,
|
||||
url=stock_item.get_absolute_url()
|
||||
# Add a transaction note to this item (the assembly)
|
||||
self.add_tracking_entry(
|
||||
StockHistoryCode.INSTALLED_CHILD_ITEM,
|
||||
user,
|
||||
notes=notes,
|
||||
deltas={
|
||||
'stockitem': stock_item.pk,
|
||||
}
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
@ -820,11 +868,25 @@ class StockItem(MPTTModel):
|
||||
# TODO - Are there any other checks that need to be performed at this stage?
|
||||
|
||||
# Add a transaction note to the parent item
|
||||
self.belongs_to.addTransactionNote(
|
||||
_("Uninstalled stock item {pk}").format(pk=str(self.pk)),
|
||||
self.belongs_to.add_tracking_entry(
|
||||
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,
|
||||
notes=notes,
|
||||
url=self.get_absolute_url(),
|
||||
deltas=tracking_info,
|
||||
location=location,
|
||||
)
|
||||
|
||||
# Mark this stock item as *not* belonging to anyone
|
||||
@ -833,19 +895,6 @@ class StockItem(MPTTModel):
|
||||
|
||||
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
|
||||
def children(self):
|
||||
""" 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):
|
||||
return self.tracking_info_count > 0
|
||||
|
||||
def addTransactionNote(self, title, user, notes='', url='', system=True):
|
||||
""" Generation a stock transaction note for this item.
|
||||
def add_tracking_entry(self, entry_type, user, deltas={}, notes='', **kwargs):
|
||||
"""
|
||||
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,
|
||||
title=title,
|
||||
tracking_type=entry_type,
|
||||
user=user,
|
||||
quantity=self.quantity,
|
||||
date=datetime.now().date(),
|
||||
date=datetime.now(),
|
||||
notes=notes,
|
||||
link=url,
|
||||
system=system
|
||||
deltas=deltas,
|
||||
)
|
||||
|
||||
track.save()
|
||||
entry.save()
|
||||
|
||||
@transaction.atomic
|
||||
def serializeStock(self, quantity, serials, user, notes='', location=None):
|
||||
@ -930,7 +995,7 @@ class StockItem(MPTTModel):
|
||||
|
||||
Args:
|
||||
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
|
||||
notes: Optional notes for tracking
|
||||
location: If specified, serialized items will be placed in the given location
|
||||
@ -982,7 +1047,7 @@ class StockItem(MPTTModel):
|
||||
new_item.location = location
|
||||
|
||||
# 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
|
||||
new_item.copyHistoryFrom(self)
|
||||
@ -991,10 +1056,18 @@ class StockItem(MPTTModel):
|
||||
new_item.copyTestResultsFrom(self)
|
||||
|
||||
# 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
|
||||
self.take_stock(quantity, user, notes=_('Serialized {n} items').format(n=quantity))
|
||||
self.take_stock(quantity, user, notes=notes)
|
||||
|
||||
@transaction.atomic
|
||||
def copyHistoryFrom(self, other):
|
||||
@ -1018,7 +1091,7 @@ class StockItem(MPTTModel):
|
||||
result.save()
|
||||
|
||||
@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.
|
||||
Stock tracking notes for this StockItem will be duplicated,
|
||||
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.
|
||||
"""
|
||||
|
||||
notes = kwargs.get('notes', '')
|
||||
|
||||
# Do not split a serialized part
|
||||
if self.serialized:
|
||||
return self
|
||||
@ -1071,17 +1146,21 @@ class StockItem(MPTTModel):
|
||||
new_stock.copyTestResultsFrom(self)
|
||||
|
||||
# Add a new tracking item for the new stock item
|
||||
new_stock.addTransactionNote(
|
||||
_("Split from existing stock"),
|
||||
new_stock.add_tracking_entry(
|
||||
StockHistoryCode.SPLIT_FROM_PARENT,
|
||||
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
|
||||
self.take_stock(
|
||||
quantity,
|
||||
user,
|
||||
f"{_('Split')} {quantity} {_('items into new stock item')}"
|
||||
notes=notes
|
||||
)
|
||||
|
||||
# Return a copy of the "new" stock item
|
||||
@ -1131,18 +1210,17 @@ class StockItem(MPTTModel):
|
||||
|
||||
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.addTransactionNote(
|
||||
msg,
|
||||
tracking_info = {}
|
||||
|
||||
self.add_tracking_entry(
|
||||
StockHistoryCode.STOCK_MOVE,
|
||||
user,
|
||||
notes=notes,
|
||||
system=True)
|
||||
deltas=tracking_info,
|
||||
location=location,
|
||||
)
|
||||
|
||||
self.save()
|
||||
|
||||
@ -1202,13 +1280,13 @@ class StockItem(MPTTModel):
|
||||
|
||||
if self.updateQuantity(count):
|
||||
|
||||
text = _('Counted {n} items').format(n=helpers.normalize(count))
|
||||
|
||||
self.addTransactionNote(
|
||||
text,
|
||||
self.add_tracking_entry(
|
||||
StockHistoryCode.STOCK_COUNT,
|
||||
user,
|
||||
notes=notes,
|
||||
system=True
|
||||
deltas={
|
||||
'quantity': float(self.quantity),
|
||||
}
|
||||
)
|
||||
|
||||
return True
|
||||
@ -1234,20 +1312,23 @@ class StockItem(MPTTModel):
|
||||
return False
|
||||
|
||||
if self.updateQuantity(self.quantity + quantity):
|
||||
text = _('Added {n} items').format(n=helpers.normalize(quantity))
|
||||
|
||||
self.addTransactionNote(
|
||||
text,
|
||||
self.add_tracking_entry(
|
||||
StockHistoryCode.STOCK_ADD,
|
||||
user,
|
||||
notes=notes,
|
||||
system=True
|
||||
deltas={
|
||||
'added': float(quantity),
|
||||
'quantity': float(self.quantity),
|
||||
}
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@transaction.atomic
|
||||
def take_stock(self, quantity, user, notes=''):
|
||||
""" Remove items from stock
|
||||
"""
|
||||
Remove items from stock
|
||||
"""
|
||||
|
||||
# Cannot remove items from a serialized part
|
||||
@ -1264,12 +1345,15 @@ class StockItem(MPTTModel):
|
||||
|
||||
if self.updateQuantity(self.quantity - quantity):
|
||||
|
||||
text = _('Removed {n1} items').format(n1=helpers.normalize(quantity))
|
||||
|
||||
self.addTransactionNote(text,
|
||||
self.add_tracking_entry(
|
||||
StockHistoryCode.STOCK_REMOVE,
|
||||
user,
|
||||
notes=notes,
|
||||
system=True)
|
||||
deltas={
|
||||
'removed': float(quantity),
|
||||
'quantity': float(self.quantity),
|
||||
}
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@ -1527,44 +1611,58 @@ class StockItemAttachment(InvenTreeAttachment):
|
||||
|
||||
|
||||
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:
|
||||
item: Link to StockItem
|
||||
item: ForeignKey reference to a particular StockItem
|
||||
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)
|
||||
link: Optional URL to external page
|
||||
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):
|
||||
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,
|
||||
related_name='tracking_info')
|
||||
def label(self):
|
||||
|
||||
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)
|
||||
|
||||
title = models.CharField(blank=False, max_length=250, verbose_name=_('Title'), help_text=_('Tracking entry title'))
|
||||
|
||||
notes = models.CharField(blank=True, max_length=512, verbose_name=_('Notes'), help_text=_('Entry notes'))
|
||||
|
||||
link = InvenTreeURLField(blank=True, verbose_name=_('Link'), help_text=_('Link to external page for further information'))
|
||||
notes = models.CharField(
|
||||
blank=True, null=True,
|
||||
max_length=512,
|
||||
verbose_name=_('Notes'),
|
||||
help_text=_('Entry notes')
|
||||
)
|
||||
|
||||
user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True)
|
||||
|
||||
system = models.BooleanField(default=False)
|
||||
|
||||
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()
|
||||
deltas = models.JSONField(null=True, blank=True)
|
||||
|
||||
|
||||
def rename_stock_item_test_result_attachment(instance, filename):
|
||||
|
@ -349,32 +349,32 @@ class StockTrackingSerializer(InvenTreeModelSerializer):
|
||||
if user_detail is not True:
|
||||
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)
|
||||
|
||||
user_detail = UserSerializerBrief(source='user', many=False, read_only=True)
|
||||
|
||||
deltas = serializers.JSONField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = StockItemTracking
|
||||
fields = [
|
||||
'pk',
|
||||
'url',
|
||||
'item',
|
||||
'item_detail',
|
||||
'date',
|
||||
'title',
|
||||
'deltas',
|
||||
'label',
|
||||
'notes',
|
||||
'link',
|
||||
'quantity',
|
||||
'tracking_type',
|
||||
'user',
|
||||
'user_detail',
|
||||
'system',
|
||||
]
|
||||
|
||||
read_only_fields = [
|
||||
'date',
|
||||
'user',
|
||||
'system',
|
||||
'quantity',
|
||||
'label',
|
||||
'tracking_type',
|
||||
]
|
||||
|
@ -94,7 +94,13 @@
|
||||
{% if item.is_expired %}
|
||||
<span class='label label-large label-large-red'>{% trans "Expired" %}</span>
|
||||
{% else %}
|
||||
{% if roles.stock.change %}
|
||||
<a href='#' id='stock-edit-status'>
|
||||
{% endif %}
|
||||
{% stock_status_label item.status large=True %}
|
||||
{% if roles.stock.change %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if item.is_stale %}
|
||||
<span class='label label-large label-large-yellow'>{% trans "Stale" %}</span>
|
||||
{% endif %}
|
||||
@ -453,6 +459,7 @@ $("#print-label").click(function() {
|
||||
printStockItemLabels([{{ item.pk }}]);
|
||||
});
|
||||
|
||||
{% if roles.stock.change %}
|
||||
$("#stock-duplicate").click(function() {
|
||||
createNewStockItem({
|
||||
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() {
|
||||
launchModalForm("{% url 'stock-item-qr' item.id %}",
|
||||
{
|
||||
|
@ -5,6 +5,8 @@ from django.core.exceptions import ValidationError
|
||||
|
||||
import datetime
|
||||
|
||||
from InvenTree.status_codes import StockHistoryCode
|
||||
|
||||
from .models import StockLocation, StockItem, StockItemTracking
|
||||
from .models import StockItemTestResult
|
||||
|
||||
@ -217,7 +219,7 @@ class StockTest(TestCase):
|
||||
track = StockItemTracking.objects.filter(item=it).latest('id')
|
||||
|
||||
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')
|
||||
|
||||
def test_self_move(self):
|
||||
@ -284,8 +286,7 @@ class StockTest(TestCase):
|
||||
# Check that a tracking item was added
|
||||
track = StockItemTracking.objects.filter(item=it).latest('id')
|
||||
|
||||
self.assertIn('Counted', track.title)
|
||||
self.assertIn('items', track.title)
|
||||
self.assertEqual(track.tracking_type, StockHistoryCode.STOCK_COUNT)
|
||||
self.assertIn('Counted items', track.notes)
|
||||
|
||||
n = it.tracking_info.count()
|
||||
@ -304,7 +305,7 @@ class StockTest(TestCase):
|
||||
# Check that a tracking item was added
|
||||
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.assertFalse(it.add_stock(-10, None))
|
||||
@ -319,7 +320,7 @@ class StockTest(TestCase):
|
||||
# Check that a tracking item was added
|
||||
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.assertTrue(it.has_tracking_info)
|
||||
|
||||
|
@ -4,7 +4,7 @@ URL lookup for Stock app
|
||||
|
||||
from django.conf.urls import url, include
|
||||
|
||||
from . import views
|
||||
from stock import views
|
||||
|
||||
location_urls = [
|
||||
|
||||
@ -24,6 +24,7 @@ location_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'^convert/', views.StockItemConvert.as_view(), name='stock-item-convert'),
|
||||
url(r'^serialize/', views.StockItemSerialize.as_view(), name='stock-item-serialize'),
|
||||
|
@ -1212,6 +1212,27 @@ class StockAdjust(AjaxView, FormMixin):
|
||||
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):
|
||||
"""
|
||||
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:
|
||||
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):
|
||||
"""
|
||||
|
@ -976,42 +976,28 @@ function loadStockLocationTable(table, options) {
|
||||
|
||||
function loadStockTrackingTable(table, options) {
|
||||
|
||||
var cols = [
|
||||
{
|
||||
field: 'pk',
|
||||
visible: false,
|
||||
},
|
||||
{
|
||||
var cols = [];
|
||||
|
||||
// Date
|
||||
cols.push({
|
||||
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';
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
// If enabled, provide a link to the referenced StockItem
|
||||
if (options.partColumn) {
|
||||
cols.push({
|
||||
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
|
||||
cols.push({
|
||||
field: 'title',
|
||||
field: 'label',
|
||||
title: '{% trans "Description" %}',
|
||||
formatter: function(value, row, index, field) {
|
||||
var html = "<b>" + value + "</b>";
|
||||
@ -1020,20 +1006,139 @@ function loadStockTrackingTable(table, options) {
|
||||
html += "<br><i>" + row.notes + "</i>";
|
||||
}
|
||||
|
||||
if (row.link) {
|
||||
html += "<br><a href='" + row.link + "'>" + row.link + "</a>";
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
});
|
||||
|
||||
// Stock transaction details
|
||||
cols.push({
|
||||
field: 'quantity',
|
||||
title: '{% trans "Quantity" %}',
|
||||
formatter: function(value, row, index, field) {
|
||||
return parseFloat(value);
|
||||
},
|
||||
field: 'deltas',
|
||||
title: '{% trans "Details" %}',
|
||||
formatter: function(details, row, index, field) {
|
||||
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({
|
||||
@ -1052,11 +1157,13 @@ function loadStockTrackingTable(table, options) {
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
// 2021-05-11 - Ability to edit or delete StockItemTracking entries is now removed
|
||||
cols.push({
|
||||
sortable: false,
|
||||
formatter: function(value, row, index, field) {
|
||||
// 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 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({
|
||||
method: 'get',
|
||||
|
@ -3,6 +3,7 @@
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% 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='purchaseOrder' options=PurchaseOrderStatus.list %}
|
||||
{% include "status_codes.html" with label='salesOrder' options=SalesOrderStatus.list %}
|
||||
|
@ -14,7 +14,7 @@ var {{ label }}Codes = {
|
||||
* Uses the values specified in "status_codes.py"
|
||||
* This function is generated by the "status_codes.html" template
|
||||
*/
|
||||
function {{ label }}StatusDisplay(key) {
|
||||
function {{ label }}StatusDisplay(key, options={}) {
|
||||
|
||||
key = String(key);
|
||||
|
||||
@ -31,5 +31,11 @@ function {{ label }}StatusDisplay(key) {
|
||||
label = '';
|
||||
}
|
||||
|
||||
return `<span class='label ${label}'>${value}</span>`;
|
||||
var classes = `label ${label}`;
|
||||
|
||||
if (options.classes) {
|
||||
classes += ' ' + options.classes;
|
||||
}
|
||||
|
||||
return `<span class='${classes}'>${value}</span>`;
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
FROM python:alpine as production
|
||||
FROM python:alpine as base
|
||||
|
||||
# GitHub source
|
||||
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 gunicorn
|
||||
|
||||
FROM base as production
|
||||
# Clone source code
|
||||
RUN echo "Downloading InvenTree from ${INVENTREE_REPO}"
|
||||
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 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
|
||||
|
||||
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
|
||||
|
||||
# exec commands should be executed from the "src" directory
|
||||
@ -97,3 +96,17 @@ WORKDIR ${INVENTREE_SRC_DIR}
|
||||
|
||||
# Let us begin
|
||||
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
7
docker/dev-config.env
Normal 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
|
59
docker/docker-compose.dev.yml
Normal file
59
docker/docker-compose.dev.yml
Normal 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
|
@ -19,6 +19,14 @@ else
|
||||
cp $INVENTREE_SRC_DIR/InvenTree/config_template.yaml $INVENTREE_CONFIG_FILE
|
||||
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..."
|
||||
|
||||
# Wait for the database to be ready
|
||||
@ -27,16 +35,14 @@ python manage.py wait_for_db
|
||||
|
||||
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
|
||||
# Ensure that the database schema are up to date
|
||||
python manage.py check || exit 1
|
||||
python manage.py migrate --noinput || 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
|
||||
|
||||
# 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
|
||||
|
19
docker/start_dev_worker.sh
Normal file
19
docker/start_dev_worker.sh
Normal 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
|
@ -11,9 +11,10 @@ django-markdownx==3.0.1 # Markdown form fields
|
||||
django-markdownify==0.8.0 # Markdown rendering
|
||||
coreapi==2.3.0 # API documentation
|
||||
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-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
|
||||
flake8==3.8.3 # PEP checking
|
||||
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
|
||||
django-q==1.3.4 # Background task scheduling
|
||||
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
|
||||
|
Loading…
Reference in New Issue
Block a user