Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2021-05-13 17:31:27 +10:00
commit bd34a9eed2
39 changed files with 1652 additions and 77 deletions

View File

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

View File

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

1
.gitignore vendored
View File

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

View File

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

View File

@ -1,14 +1,31 @@
function attachClipboard(selector) { function attachClipboard(selector, containerselector, textElement) {
// set container
new ClipboardJS(selector, { if (containerselector){
text: function(trigger) { containerselector = document.getElementById(containerselector);
var content = trigger.parentElement.parentElement.textContent; } else {
containerselector = document.body;
return content.trim();
} }
// set text-function
if (textElement){
text = function() {
return document.getElementById(textElement).textContent;
}
} else {
text = function() {
var content = trigger.parentElement.parentElement.textContent;return content.trim();
}
}
// create Clipboard
var cis = new ClipboardJS(selector, {
text: text,
container: containerselector
}); });
console.log(cis);
} }
function inventreeDocReady() { function inventreeDocReady() {
/* Run this function when the HTML document is loaded. /* Run this function when the HTML document is loaded.
* This will be called for every page that extends "base.html" * This will be called for every page that extends "base.html"
@ -62,6 +79,8 @@ function inventreeDocReady() {
// Initialize clipboard-buttons // Initialize clipboard-buttons
attachClipboard('.clip-btn'); attachClipboard('.clip-btn');
attachClipboard('.clip-btn', 'modal-about'); // modals
attachClipboard('.clip-btn-version', 'modal-about', 'about-copy-text'); // version-text
} }

View File

@ -19,12 +19,12 @@
<tr> <tr>
<td><span class='fas fa-info'></span></td> <td><span class='fas fa-info'></span></td>
<td>{% trans "Description" %}</td> <td>{% trans "Description" %}</td>
<td>{{ build.title }}</td> <td>{{ build.title }}{% include "clip.html"%}</td>
</tr> </tr>
<tr> <tr>
<td><span class='fas fa-shapes'></span></td> <td><span class='fas fa-shapes'></span></td>
<td>{% trans "Part" %}</td> <td>{% trans "Part" %}</td>
<td><a href="{% url 'part-build' build.part.id %}">{{ build.part.full_name }}</a></td> <td><a href="{% url 'part-build' build.part.id %}">{{ build.part.full_name }}</a>{% include "clip.html"%}</td>
</tr> </tr>
<tr> <tr>
<td></td> <td></td>
@ -35,7 +35,7 @@
<td>{% trans "Stock Source" %}</td> <td>{% trans "Stock Source" %}</td>
<td> <td>
{% if build.take_from %} {% if build.take_from %}
<a href="{% url 'stock-location-detail' build.take_from.id %}">{{ build.take_from }}</a> <a href="{% url 'stock-location-detail' build.take_from.id %}">{{ build.take_from }}</a>{% include "clip.html"%}
{% else %} {% else %}
<i>{% trans "Stock can be taken from any available location." %}</i> <i>{% trans "Stock can be taken from any available location." %}</i>
{% endif %} {% endif %}
@ -48,7 +48,7 @@
{% if build.destination %} {% if build.destination %}
<a href="{% url 'stock-location-detail' build.destination.id %}"> <a href="{% url 'stock-location-detail' build.destination.id %}">
{{ build.destination }} {{ build.destination }}
</a> </a>{% include "clip.html"%}
{% else %} {% else %}
<i>{% trans "Destination location not specified" %}</i> <i>{% trans "Destination location not specified" %}</i>
{% endif %} {% endif %}
@ -68,28 +68,28 @@
<tr> <tr>
<td><span class='fas fa-layer-group'></span></td> <td><span class='fas fa-layer-group'></span></td>
<td>{% trans "Batch" %}</td> <td>{% trans "Batch" %}</td>
<td>{{ build.batch }}</td> <td>{{ build.batch }}{% include "clip.html"%}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if build.parent %} {% if build.parent %}
<tr> <tr>
<td><span class='fas fa-sitemap'></span></td> <td><span class='fas fa-sitemap'></span></td>
<td>{% trans "Parent Build" %}</td> <td>{% trans "Parent Build" %}</td>
<td><a href="{% url 'build-detail' build.parent.id %}">{{ build.parent }}</a></td> <td><a href="{% url 'build-detail' build.parent.id %}">{{ build.parent }}</a>{% include "clip.html"%}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if build.sales_order %} {% if build.sales_order %}
<tr> <tr>
<td><span class='fas fa-dolly'></span></td> <td><span class='fas fa-dolly'></span></td>
<td>{% trans "Sales Order" %}</td> <td>{% trans "Sales Order" %}</td>
<td><a href="{% url 'so-detail' build.sales_order.id %}">{{ build.sales_order }}</a></td> <td><a href="{% url 'so-detail' build.sales_order.id %}">{{ build.sales_order }}</a>{% include "clip.html"%}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if build.link %} {% if build.link %}
<tr> <tr>
<td><span class='fas fa-link'></span></td> <td><span class='fas fa-link'></span></td>
<td>{% trans "External Link" %}</td> <td>{% trans "External Link" %}</td>
<td><a href="{{ build.link }}">{{ build.link }}</a></td> <td><a href="{{ build.link }}">{{ build.link }}</a>{% include "clip.html"%}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if build.issued_by %} {% if build.issued_by %}

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

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

View File

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

View File

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

View File

@ -68,35 +68,35 @@
<tr> <tr>
<td><span class='fas fa-globe'></span></td> <td><span class='fas fa-globe'></span></td>
<td>{% trans "Website" %}</td> <td>{% trans "Website" %}</td>
<td><a href="{{ company.website }}">{{ company.website }}</a></td> <td><a href="{{ company.website }}">{{ company.website }}</a>{% include "clip.html"%}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if company.address %} {% if company.address %}
<tr> <tr>
<td><span class='fas fa-map-marked-alt'></span></td> <td><span class='fas fa-map-marked-alt'></span></td>
<td>{% trans "Address" %}</td> <td>{% trans "Address" %}</td>
<td>{{ company.address }}</td> <td>{{ company.address }}{% include "clip.html"%}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if company.phone %} {% if company.phone %}
<tr> <tr>
<td><span class='fas fa-phone'></span></td> <td><span class='fas fa-phone'></span></td>
<td>{% trans "Phone" %}</td> <td>{% trans "Phone" %}</td>
<td>{{ company.phone }}</td> <td>{% include "tel.html" with tel=company.phone %}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if company.email %} {% if company.email %}
<tr> <tr>
<td><span class='fas fa-at'></span></td> <td><span class='fas fa-at'></span></td>
<td>{% trans "Email" %}</td> <td>{% trans "Email" %}</td>
<td>{{ company.email }}</td> <td>{% include "mail.html" with mail=company.email %}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if company.contact %} {% if company.contact %}
<tr> <tr>
<td><span class='fas fa-user'></span></td> <td><span class='fas fa-user'></span></td>
<td>{% trans "Contact" %}</td> <td>{% trans "Contact" %}</td>
<td>{{ company.contact }}</td> <td>{{ company.contact }}{% include "clip.html"%}</td>
</tr> </tr>
{% endif %} {% endif %}
</table> </table>

View File

@ -19,20 +19,20 @@
<tr> <tr>
<td><span class='fas fa-font'></span></td> <td><span class='fas fa-font'></span></td>
<td>{% trans "Company Name" %}</td> <td>{% trans "Company Name" %}</td>
<td>{{ company.name }}</td> <td>{{ company.name }}{% include "clip.html"%}</td>
</tr> </tr>
{% if company.description %} {% if company.description %}
<tr> <tr>
<td><span class='fas fa-info'></span></td> <td><span class='fas fa-info'></span></td>
<td>{% trans "Description" %}</td> <td>{% trans "Description" %}</td>
<td>{{ company.description }}</td> <td>{{ company.description }}{% include "clip.html"%}</td>
</tr> </tr>
{% endif %} {% endif %}
<tr> <tr>
<td><span class='fas fa-globe'></span></td> <td><span class='fas fa-globe'></span></td>
<td>{% trans "Website" %}</td> <td>{% trans "Website" %}</td>
<td> <td>
{% if company.website %}<a href='{{ company.website }}'>{{ company.website }}</a> {% if company.website %}<a href='{{ company.website }}'>{{ company.website }}</a>{% include "clip.html"%}
{% else %}<i>{% trans "No website specified" %}</i> {% else %}<i>{% trans "No website specified" %}</i>
{% endif %} {% endif %}
</td> </td>

View File

@ -62,7 +62,7 @@ src="{% static 'img/blank_image.png' %}"
<td>{% trans "Internal Part" %}</td> <td>{% trans "Internal Part" %}</td>
<td> <td>
{% if part.part %} {% if part.part %}
<a href="{% url 'part-manufacturers' part.part.id %}">{{ part.part.full_name }}</a> <a href="{% url 'part-manufacturers' part.part.id %}">{{ part.part.full_name }}</a>{% include "clip.html"%}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -70,24 +70,24 @@ src="{% static 'img/blank_image.png' %}"
<tr> <tr>
<td></td> <td></td>
<td>{% trans "Description" %}</td> <td>{% trans "Description" %}</td>
<td>{{ part.description }}</td> <td>{{ part.description }}{% include "clip.html"%}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if part.link %} {% if part.link %}
<tr> <tr>
<td><span class='fas fa-link'></span></td> <td><span class='fas fa-link'></span></td>
<td>{% trans "External Link" %}</td> <td>{% trans "External Link" %}</td>
<td><a href="{{ part.link }}">{{ part.link }}</a></td> <td><a href="{{ part.link }}">{{ part.link }}</a>{% include "clip.html"%}</td>
</tr> </tr>
{% endif %} {% endif %}
<tr> <tr>
<td><span class='fas fa-industry'></span></td> <td><span class='fas fa-industry'></span></td>
<td>{% trans "Manufacturer" %}</td> <td>{% trans "Manufacturer" %}</td>
<td><a href="{% url 'company-detail-manufacturer-parts' part.manufacturer.id %}">{{ part.manufacturer.name }}</a></td></tr> <td><a href="{% url 'company-detail-manufacturer-parts' part.manufacturer.id %}">{{ part.manufacturer.name }}</a>{% include "clip.html"%}</td></tr>
<tr> <tr>
<td><span class='fas fa-hashtag'></span></td> <td><span class='fas fa-hashtag'></span></td>
<td>{% trans "MPN" %}</td> <td>{% trans "MPN" %}</td>
<td>{{ part.MPN }}</td> <td>{{ part.MPN }}{% include "clip.html"%}</td>
</tr> </tr>
</table> </table>
{% endblock %} {% endblock %}

View File

@ -61,7 +61,7 @@ src="{% static 'img/blank_image.png' %}"
<td>{% trans "Internal Part" %}</td> <td>{% trans "Internal Part" %}</td>
<td> <td>
{% if part.part %} {% if part.part %}
<a href="{% url 'part-suppliers' part.part.id %}">{{ part.part.full_name }}</a> <a href="{% url 'part-suppliers' part.part.id %}">{{ part.part.full_name }}</a>{% include "clip.html"%}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -69,51 +69,52 @@ src="{% static 'img/blank_image.png' %}"
<tr> <tr>
<td></td> <td></td>
<td>{% trans "Description" %}</td> <td>{% trans "Description" %}</td>
<td>{{ part.description }}</td> <td>{{ part.description }}{% include "clip.html"%}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if part.link %} {% if part.link %}
<tr> <tr>
<td><span class='fas fa-link'></span></td> <td><span class='fas fa-link'></span></td>
<td>{% trans "External Link" %}</td> <td>{% trans "External Link" %}</td>
<td><a href="{{ part.link }}">{{ part.link }}</a></td> <td><a href="{{ part.link }}">{{ part.link }}</a>{% include "clip.html"%}</td>
</tr> </tr>
{% endif %} {% endif %}
<tr> <tr>
<td><span class='fas fa-building'></span></td> <td><span class='fas fa-building'></span></td>
<td>{% trans "Supplier" %}</td> <td>{% trans "Supplier" %}</td>
<td><a href="{% url 'company-detail-supplier-parts' part.supplier.id %}">{{ part.supplier.name }}</a></td></tr> <td><a href="{% url 'company-detail-supplier-parts' part.supplier.id %}">{{ part.supplier.name }}</a>{% include "clip.html"%}</td></tr>
<tr> <tr>
<td><span class='fas fa-hashtag'></span></td> <td><span class='fas fa-hashtag'></span></td>
<td>{% trans "SKU" %}</td> <td>{% trans "SKU" %}</td>
<td>{{ part.SKU }}</tr> <td>{{ part.SKU }}{% include "clip.html"%}</tr>
</tr> </tr>
{% if part.manufacturer_part.manufacturer %} {% if part.manufacturer_part.manufacturer %}
<tr> <tr>
<td><span class='fas fa-industry'></span></td> <td><span class='fas fa-industry'></span></td>
<td>{% trans "Manufacturer" %}</td> <td>{% trans "Manufacturer" %}</td>
<td><a href="{% url 'company-detail-manufacturer-parts' part.manufacturer_part.manufacturer.id %}">{{ part.manufacturer_part.manufacturer.name }}</a></td> <td><a href="{% url 'company-detail-manufacturer-parts' part.manufacturer_part.manufacturer.id %}">
{{ part.manufacturer_part.manufacturer.name }}</a>{% include "clip.html"%}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if part.manufacturer_part.MPN %} {% if part.manufacturer_part.MPN %}
<tr> <tr>
<td><span class='fas fa-hashtag'></span></td> <td><span class='fas fa-hashtag'></span></td>
<td>{% trans "MPN" %}</td> <td>{% trans "MPN" %}</td>
<td><a href="{% url 'manufacturer-part-detail' part.manufacturer_part.id %}">{{ part.manufacturer_part.MPN }}</a></td> <td><a href="{% url 'manufacturer-part-detail' part.manufacturer_part.id %}">{{ part.manufacturer_part.MPN }}</a>{% include "clip.html"%}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if part.packaging %} {% if part.packaging %}
<tr> <tr>
<td><span class='fas fa-cube'></span></td> <td><span class='fas fa-cube'></span></td>
<td>{% trans "Packaging" %}</td> <td>{% trans "Packaging" %}</td>
<td>{{ part.packaging }}</td> <td>{{ part.packaging }}{% include "clip.html"%}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if part.note %} {% if part.note %}
<tr> <tr>
<td><span class='fas fa-sticky-note'></span></td> <td><span class='fas fa-sticky-note'></span></td>
<td>{% trans "Note" %}</td> <td>{% trans "Note" %}</td>
<td>{{ part.note }}</td> <td>{{ part.note }}{% include "clip.html"%}</td>
</tr> </tr>
{% endif %} {% endif %}
</table> </table>

View File

@ -28,14 +28,14 @@
<tr><td>{% trans "External Link" %}</td><td><a href="{{ part.link }}">{{ part.link }}</a></td></tr> <tr><td>{% trans "External Link" %}</td><td><a href="{{ part.link }}">{{ part.link }}</a></td></tr>
{% endif %} {% endif %}
{% if part.description %} {% if part.description %}
<tr><td>{% trans "Description" %}</td><td>{{ part.description }}</td></tr> <tr><td>{% trans "Description" %}</td><td>{{ part.description }}{% include "clip.html"%}</td></tr>
{% endif %} {% endif %}
{% if part.manufacturer %} {% if part.manufacturer %}
<tr><td>{% trans "Manufacturer" %}</td><td>{{ part.manufacturer }}</td></tr> <tr><td>{% trans "Manufacturer" %}</td><td>{{ part.manufacturer }}{% include "clip.html"%}</td></tr>
<tr><td>{% trans "MPN" %}</td><td>{{ part.MPN }}</td></tr> <tr><td>{% trans "MPN" %}</td><td>{{ part.MPN }}{% include "clip.html"%}</td></tr>
{% endif %} {% endif %}
{% if part.note %} {% if part.note %}
<tr><td>{% trans "Note" %}</td><td>{{ part.note }}</td></tr> <tr><td>{% trans "Note" %}</td><td>{{ part.note }}{% include "clip.html"%}</td></tr>
{% endif %} {% endif %}
</table> </table>

View File

@ -63,16 +63,23 @@ class LabelPrintMixin:
# In debug mode, generate single HTML output, rather than PDF # In debug mode, generate single HTML output, rather than PDF
debug_mode = common.models.InvenTreeSetting.get_setting('REPORT_DEBUG_MODE') debug_mode = common.models.InvenTreeSetting.get_setting('REPORT_DEBUG_MODE')
label_name = "label.pdf"
# Merge one or more PDF files into a single download # Merge one or more PDF files into a single download
for item in items_to_print: for item in items_to_print:
label = self.get_object() label = self.get_object()
label.object_to_print = item label.object_to_print = item
label_name = label.generate_filename(request)
if debug_mode: if debug_mode:
outputs.append(label.render_as_string(request)) outputs.append(label.render_as_string(request))
else: else:
outputs.append(label.render(request)) outputs.append(label.render(request))
if not label_name.endswith(".pdf"):
label_name += ".pdf"
if debug_mode: if debug_mode:
""" """
Contatenate all rendered templates into a single HTML string, Contatenate all rendered templates into a single HTML string,
@ -103,7 +110,7 @@ class LabelPrintMixin:
return InvenTree.helpers.DownloadFile( return InvenTree.helpers.DownloadFile(
pdf, pdf,
'inventree_label.pdf', label_name,
content_type='application/pdf' content_type='application/pdf'
) )

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2 on 2021-05-13 03:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('label', '0006_auto_20210222_1535'),
]
operations = [
migrations.AddField(
model_name='stockitemlabel',
name='filename_pattern',
field=models.CharField(default='label.pdf', help_text='Pattern for generating label filenames', max_length=100, verbose_name='Filename Pattern'),
),
migrations.AddField(
model_name='stocklocationlabel',
name='filename_pattern',
field=models.CharField(default='label.pdf', help_text='Pattern for generating label filenames', max_length=100, verbose_name='Filename Pattern'),
),
]

View File

@ -15,6 +15,7 @@ from django.db import models
from django.core.validators import FileExtensionValidator, MinValueValidator from django.core.validators import FileExtensionValidator, MinValueValidator
from django.core.exceptions import ValidationError, FieldError from django.core.exceptions import ValidationError, FieldError
from django.template import Template, Context
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -138,6 +139,13 @@ class LabelTemplate(models.Model):
validators=[MinValueValidator(2)] validators=[MinValueValidator(2)]
) )
filename_pattern = models.CharField(
default="label.pdf",
verbose_name=_('Filename Pattern'),
help_text=_('Pattern for generating label filenames'),
max_length=100,
)
@property @property
def template_name(self): def template_name(self):
""" """
@ -162,6 +170,19 @@ class LabelTemplate(models.Model):
return {} return {}
def generate_filename(self, request, **kwargs):
"""
Generate a filename for this label
"""
template_string = Template(self.filename_pattern)
ctx = self.context(request)
context = Context(ctx)
return template_string.render(context)
def context(self, request): def context(self, request):
""" """
Provides context data to the template. Provides context data to the template.
@ -201,6 +222,7 @@ class LabelTemplate(models.Model):
self.template_name, self.template_name,
base_url=request.build_absolute_uri("/"), base_url=request.build_absolute_uri("/"),
presentational_hints=True, presentational_hints=True,
filename=self.generate_filename(request),
**kwargs **kwargs
) )

View File

@ -33,7 +33,7 @@ src="{% static 'img/blank_image.png' %}"
{% endif %} {% endif %}
</h3> </h3>
<hr> <hr>
<p>{{ order.description }}</p> <p>{{ order.description }}{% include "clip.html"%}</p>
<div class='btn-row'> <div class='btn-row'>
<div class='btn-group action-buttons' role='group'> <div class='btn-group action-buttons' role='group'>
<button type='button' class='btn btn-default' id='print-order-report' title='{% trans "Print" %}'> <button type='button' class='btn btn-default' id='print-order-report' title='{% trans "Print" %}'>
@ -75,7 +75,7 @@ src="{% static 'img/blank_image.png' %}"
<tr> <tr>
<td><span class='fas fa-hashtag'></span></td> <td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Order Reference" %}</td> <td>{% trans "Order Reference" %}</td>
<td>{{ order.reference }}</td> <td>{{ order.reference }}{% include "clip.html"%}</td>
</tr> </tr>
<tr> <tr>
<td><span class='fas fa-info'></span></td> <td><span class='fas fa-info'></span></td>
@ -90,20 +90,20 @@ src="{% static 'img/blank_image.png' %}"
<tr> <tr>
<td><span class='fas fa-building'></span></td> <td><span class='fas fa-building'></span></td>
<td>{% trans "Supplier" %}</td> <td>{% trans "Supplier" %}</td>
<td><a href="{% url 'company-detail' order.supplier.id %}">{{ order.supplier.name }}</a></td> <td><a href="{% url 'company-detail' order.supplier.id %}">{{ order.supplier.name }}</a>{% include "clip.html"%}</td>
</tr> </tr>
{% if order.supplier_reference %} {% if order.supplier_reference %}
<tr> <tr>
<td><span class='fas fa-hashtag'></span></td> <td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Supplier Reference" %}</td> <td>{% trans "Supplier Reference" %}</td>
<td>{{ order.supplier_reference }}</td> <td>{{ order.supplier_reference }}{% include "clip.html"%}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if order.link %} {% if order.link %}
<tr> <tr>
<td><span class='fas fa-link'></span></td> <td><span class='fas fa-link'></span></td>
<td>External Link</td> <td>External Link</td>
<td><a href="{{ order.link }}">{{ order.link }}</a></td> <td><a href="{{ order.link }}">{{ order.link }}</a>{% include "clip.html"%}</td>
</tr> </tr>
{% endif %} {% endif %}
<tr> <tr>

View File

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

View File

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

View File

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

View File

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

View File

@ -42,7 +42,7 @@ src="{% static 'img/blank_image.png' %}"
{% endif %} {% endif %}
</h3> </h3>
<hr> <hr>
<p>{{ order.description }}</p> <p>{{ order.description }}{% include "clip.html"%}</p>
<div class='btn-row'> <div class='btn-row'>
<div class='btn-group action-buttons'> <div class='btn-group action-buttons'>
<button type='button' class='btn btn-default' id='print-order-report' title='{% trans "Print" %}'> <button type='button' class='btn btn-default' id='print-order-report' title='{% trans "Print" %}'>
@ -75,7 +75,7 @@ src="{% static 'img/blank_image.png' %}"
<tr> <tr>
<td><span class='fas fa-hashtag'></span></td> <td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Order Reference" %}</td> <td>{% trans "Order Reference" %}</td>
<td>{{ order.reference }}</td> <td>{{ order.reference }}{% include "clip.html"%}</td>
</tr> </tr>
<tr> <tr>
<td><span class='fas fa-info'></span></td> <td><span class='fas fa-info'></span></td>
@ -90,20 +90,20 @@ src="{% static 'img/blank_image.png' %}"
<tr> <tr>
<td><span class='fas fa-building'></span></td> <td><span class='fas fa-building'></span></td>
<td>{% trans "Customer" %}</td> <td>{% trans "Customer" %}</td>
<td><a href="{% url 'company-detail' order.customer.id %}">{{ order.customer.name }}</a></td> <td><a href="{% url 'company-detail' order.customer.id %}">{{ order.customer.name }}</a>{% include "clip.html"%}</td>
</tr> </tr>
{% if order.customer_reference %} {% if order.customer_reference %}
<tr> <tr>
<td><span class='fas fa-hashtag'></span></td> <td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Customer Reference" %}</td> <td>{% trans "Customer Reference" %}</td>
<td>{{ order.customer_reference }}</td> <td>{{ order.customer_reference }}{% include "clip.html"%}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if order.link %} {% if order.link %}
<tr> <tr>
<td><span class='fas fa-link'></span></td> <td><span class='fas fa-link'></span></td>
<td>External Link</td> <td>External Link</td>
<td><a href="{{ order.link }}">{{ order.link }}</a></td> <td><a href="{{ order.link }}">{{ order.link }}</a>{% include "clip.html"%}</td>
</tr> </tr>
{% endif %} {% endif %}
<tr> <tr>

View File

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

View File

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

View File

@ -58,14 +58,14 @@
<tr> <tr>
<td></td> <td></td>
<td><strong>{% trans "Variant Of" %}</strong></td> <td><strong>{% trans "Variant Of" %}</strong></td>
<td><a href="{% url 'part-detail' part.variant_of.id %}">{{ part.variant_of.full_name }}</a></td> <td><a href="{% url 'part-detail' part.variant_of.id %}">{{ part.variant_of.full_name }}</a>{% include "clip.html"%}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if part.keywords %} {% if part.keywords %}
<tr> <tr>
<td><span class='fas fa-key'></span></td> <td><span class='fas fa-key'></span></td>
<td><strong>{% trans "Keywords" %}</strong></td> <td><strong>{% trans "Keywords" %}</strong></td>
<td>{{ part.keywords }}</td> <td>{{ part.keywords }}{% include "clip.html"%}</td>
</tr> </tr>
{% endif %} {% endif %}
<tr> <tr>
@ -73,7 +73,7 @@
<td><strong>{% trans "Category" %}</strong></td> <td><strong>{% trans "Category" %}</strong></td>
<td> <td>
{% if part.category %} {% if part.category %}
<a href="{% url 'category-detail' part.category.id %}">{{ part.category.pathstring }}</a> <a href="{% url 'category-detail' part.category.id %}">{{ part.category.pathstring }}</a>{% include "clip.html"%}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -81,14 +81,14 @@
<tr> <tr>
<td><span class='fas fa-link'></span></td> <td><span class='fas fa-link'></span></td>
<td><strong>{% trans "External Link" %}</strong></td> <td><strong>{% trans "External Link" %}</strong></td>
<td><a href="{{ part.link }}">{{ part.link }}</a></td> <td><a href="{{ part.link }}">{{ part.link }}</a>{% include "clip.html"%}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if part.default_location %} {% if part.default_location %}
<tr> <tr>
<td><span class='fas fa-map-marker-alt'></span></td> <td><span class='fas fa-map-marker-alt'></span></td>
<td><strong>{% trans "Default Location" %}</strong></td> <td><strong>{% trans "Default Location" %}</strong></td>
<td><a href="{% url 'stock-location-detail' part.default_location.id %}">{{ part.default_location.pathstring }}</a></td> <td><a href="{% url 'stock-location-detail' part.default_location.id %}">{{ part.default_location.pathstring }}</a>{% include "clip.html"%}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if part.default_supplier %} {% if part.default_supplier %}
@ -96,15 +96,15 @@
<td></td> <td></td>
<td><strong>{% trans "Default Supplier" %}</strong></td> <td><strong>{% trans "Default Supplier" %}</strong></td>
<td><a href="{% url 'supplier-part-detail' part.default_supplier.id %}"> <td><a href="{% url 'supplier-part-detail' part.default_supplier.id %}">
{{ part.default_supplier.supplier.name }} | {{ part.default_supplier.SKU }}{% include "clip.html"%} {{ part.default_supplier.supplier.name }} | {{ part.default_supplier.SKU }}
</a></td> </a>{% include "clip.html"%}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if part.units %} {% if part.units %}
<tr> <tr>
<td></td> <td></td>
<td><strong>{% trans "Units" %}</strong></td> <td><strong>{% trans "Units" %}</strong></td>
<td>{{ part.units }}</td> <td>{{ part.units }}{% include "clip.html"%}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if part.minimum_stock > 0 %} {% if part.minimum_stock > 0 %}

View File

@ -208,16 +208,24 @@ class ReportPrintMixin:
# In debug mode, generate single HTML output, rather than PDF # In debug mode, generate single HTML output, rather than PDF
debug_mode = common.models.InvenTreeSetting.get_setting('REPORT_DEBUG_MODE') debug_mode = common.models.InvenTreeSetting.get_setting('REPORT_DEBUG_MODE')
# Start with a default report name
report_name = "report.pdf"
# Merge one or more PDF files into a single download # Merge one or more PDF files into a single download
for item in items_to_print: for item in items_to_print:
report = self.get_object() report = self.get_object()
report.object_to_print = item report.object_to_print = item
report_name = report.generate_filename(request)
if debug_mode: if debug_mode:
outputs.append(report.render_as_string(request)) outputs.append(report.render_as_string(request))
else: else:
outputs.append(report.render(request)) outputs.append(report.render(request))
if not report_name.endswith('.pdf'):
report_name += '.pdf'
if debug_mode: if debug_mode:
""" """
Contatenate all rendered templates into a single HTML string, Contatenate all rendered templates into a single HTML string,
@ -248,7 +256,7 @@ class ReportPrintMixin:
return InvenTree.helpers.DownloadFile( return InvenTree.helpers.DownloadFile(
pdf, pdf,
'inventree_report.pdf', report_name,
content_type='application/pdf' content_type='application/pdf'
) )

View File

@ -0,0 +1,38 @@
# Generated by Django 3.2 on 2021-05-13 03:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('report', '0015_auto_20210403_1837'),
]
operations = [
migrations.AddField(
model_name='billofmaterialsreport',
name='filename_pattern',
field=models.CharField(default='report.pdf', help_text='Pattern for generating report filenames', max_length=100, verbose_name='Filename Pattern'),
),
migrations.AddField(
model_name='buildreport',
name='filename_pattern',
field=models.CharField(default='report.pdf', help_text='Pattern for generating report filenames', max_length=100, verbose_name='Filename Pattern'),
),
migrations.AddField(
model_name='purchaseorderreport',
name='filename_pattern',
field=models.CharField(default='report.pdf', help_text='Pattern for generating report filenames', max_length=100, verbose_name='Filename Pattern'),
),
migrations.AddField(
model_name='salesorderreport',
name='filename_pattern',
field=models.CharField(default='report.pdf', help_text='Pattern for generating report filenames', max_length=100, verbose_name='Filename Pattern'),
),
migrations.AddField(
model_name='testreport',
name='filename_pattern',
field=models.CharField(default='report.pdf', help_text='Pattern for generating report filenames', max_length=100, verbose_name='Filename Pattern'),
),
]

View File

@ -16,6 +16,7 @@ from django.conf import settings
from django.core.exceptions import ValidationError, FieldError from django.core.exceptions import ValidationError, FieldError
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.template import Template, Context
from django.core.files.storage import FileSystemStorage from django.core.files.storage import FileSystemStorage
from django.core.validators import FileExtensionValidator from django.core.validators import FileExtensionValidator
@ -224,6 +225,7 @@ class ReportTemplateBase(ReportBase):
All context to be passed to the renderer. All context to be passed to the renderer.
""" """
# Generate custom context data based on the particular report subclass
context = self.get_context_data(request) context = self.get_context_data(request)
context['base_url'] = common.models.InvenTreeSetting.get_setting('INVENTREE_BASE_URL') context['base_url'] = common.models.InvenTreeSetting.get_setting('INVENTREE_BASE_URL')
@ -238,9 +240,22 @@ class ReportTemplateBase(ReportBase):
return context return context
def generate_filename(self, request, **kwargs):
"""
Generate a filename for this report
"""
template_string = Template(self.filename_pattern)
ctx = self.context(request)
context = Context(ctx)
return template_string.render(context)
def render_as_string(self, request, **kwargs): def render_as_string(self, request, **kwargs):
""" """
Render the report to a HTML stiring. Render the report to a HTML string.
Useful for debug mode (viewing generated code) Useful for debug mode (viewing generated code)
""" """
@ -263,12 +278,20 @@ class ReportTemplateBase(ReportBase):
self.template_name, self.template_name,
base_url=request.build_absolute_uri("/"), base_url=request.build_absolute_uri("/"),
presentational_hints=True, presentational_hints=True,
filename=self.generate_filename(request),
**kwargs) **kwargs)
return wp.render_to_response( return wp.render_to_response(
self.context(request), self.context(request),
**kwargs) **kwargs)
filename_pattern = models.CharField(
default="report.pdf",
verbose_name=_('Filename Pattern'),
help_text=_('Pattern for generating report filenames'),
max_length=100,
)
enabled = models.BooleanField( enabled = models.BooleanField(
default=True, default=True,
verbose_name=_('Enabled'), verbose_name=_('Enabled'),
@ -326,6 +349,7 @@ class TestReport(ReportTemplateBase):
return { return {
'stock_item': stock_item, 'stock_item': stock_item,
'serial': stock_item.serial,
'part': stock_item.part, 'part': stock_item.part,
'results': stock_item.testResultMap(include_installed=self.include_installed), 'results': stock_item.testResultMap(include_installed=self.include_installed),
'result_list': stock_item.testResultList(include_installed=self.include_installed) 'result_list': stock_item.testResultList(include_installed=self.include_installed)
@ -367,6 +391,7 @@ class BuildReport(ReportTemplateBase):
'bom_items': my_build.part.get_bom_items(), 'bom_items': my_build.part.get_bom_items(),
'reference': my_build.reference, 'reference': my_build.reference,
'quantity': my_build.quantity, 'quantity': my_build.quantity,
'title': str(my_build),
} }

View File

@ -21,7 +21,7 @@
<td><span class='fas fa-hashtag'></span></td> <td><span class='fas fa-hashtag'></span></td>
<td>{% trans "InvenTree Version" %}</td> <td>{% trans "InvenTree Version" %}</td>
<td> <td>
<a href="https://github.com/inventree/InvenTree/releases">{% inventree_version %}</a> <a href="https://github.com/inventree/InvenTree/releases">{% inventree_version %}</a>{% include "clip.html" %}
{% if up_to_date %} {% if up_to_date %}
<span class='label label-green float-right'>{% trans "Up to Date" %}</span> <span class='label label-green float-right'>{% trans "Up to Date" %}</span>
{% else %} {% else %}
@ -32,20 +32,20 @@
<tr> <tr>
<td><span class='fas fa-hashtag'></span></td> <td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Django Version" %}</td> <td>{% trans "Django Version" %}</td>
<td><a href="https://www.djangoproject.com/">{% django_version %}</a></td> <td><a href="https://www.djangoproject.com/">{% django_version %}</a>{% include "clip.html" %}</td>
</tr> </tr>
{% inventree_commit_hash as hash %} {% inventree_commit_hash as hash %}
{% if hash %} {% if hash %}
<tr> <tr>
<td><span class='fas fa-code-branch'></span></td> <td><span class='fas fa-code-branch'></span></td>
<td>{% trans "Commit Hash" %}</td><td>{{ hash }}</td> <td>{% trans "Commit Hash" %}</td><td>{{ hash }}{% include "clip.html" %}</td>
</tr> </tr>
{% endif %} {% endif %}
{% inventree_commit_date as commit_date %} {% inventree_commit_date as commit_date %}
{% if commit_date %} {% if commit_date %}
<tr> <tr>
<td><span class='fas fa-calendar-alt'></span></td> <td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Commit Date" %}</td><td>{{ commit_date }}</td> <td>{% trans "Commit Date" %}</td><td>{{ commit_date }}{% include "clip.html" %}</td>
</tr> </tr>
{% endif %} {% endif %}
<tr> <tr>
@ -73,6 +73,14 @@
<td>{% trans "Submit Bug Report" %}</td> <td>{% trans "Submit Bug Report" %}</td>
<td><a href='{% inventree_github_url %}/issues'>{% inventree_github_url %}issues</a></td> <td><a href='{% inventree_github_url %}/issues'>{% inventree_github_url %}issues</a></td>
</tr> </tr>
<tr><td></td><td></td>
<td>
<span style="display: none;" id="about-copy-text">{% include "version.html" %}</span>
<span class="float-right">
<button class="btn clip-btn-version" type="button" data-toggle='tooltip' title='{% trans "copy to clipboard" %}'><i class="fas fa-copy"></i> {% trans "copy version information" %}</button>
</span>
</td>
</tr>
</table> </table>
</div> </div>

View File

@ -0,0 +1 @@
<a href="mailto:{{ mail }}">{{ mail }}</a>{% include "clip.html"%}

View File

@ -0,0 +1 @@
<a href="tel:{{ tel }}">{{ tel }}</a>{% include "clip.html"%}

View File

@ -0,0 +1,5 @@
# Version Information:{% load inventree_extras %}
InvenTree-Version: {% inventree_version %}
Django Version: {% django_version %}
{% inventree_commit_hash as hash %}{% if hash %}Commit Hash: {{ hash }}{% endif %}
{% inventree_commit_date as commit_date %}{% if commit_date %}Commit Date: {{ commit_date }}{% endif %}

View File

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

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

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

View File

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

View File

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

View File

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

View File

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