mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
commit
bd34a9eed2
1
.github/workflows/docker_build.yaml
vendored
1
.github/workflows/docker_build.yaml
vendored
@ -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
|
||||||
|
1
.github/workflows/docker_publish.yaml
vendored
1
.github/workflows/docker_publish.yaml
vendored
@ -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
1
.gitignore
vendored
@ -31,6 +31,7 @@ var/
|
|||||||
*.log
|
*.log
|
||||||
local_settings.py
|
local_settings.py
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
*.sqlite3-journal
|
||||||
*.backup
|
*.backup
|
||||||
*.old
|
*.old
|
||||||
|
|
||||||
|
@ -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']
|
||||||
|
@ -1,14 +1,31 @@
|
|||||||
function attachClipboard(selector) {
|
function attachClipboard(selector, containerselector, textElement) {
|
||||||
|
// set container
|
||||||
|
if (containerselector){
|
||||||
|
containerselector = document.getElementById(containerselector);
|
||||||
|
} else {
|
||||||
|
containerselector = document.body;
|
||||||
|
}
|
||||||
|
|
||||||
new ClipboardJS(selector, {
|
// set text-function
|
||||||
text: function(trigger) {
|
if (textElement){
|
||||||
var content = trigger.parentElement.parentElement.textContent;
|
text = function() {
|
||||||
|
return document.getElementById(textElement).textContent;
|
||||||
return content.trim();
|
|
||||||
}
|
}
|
||||||
|
} 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
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
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 -*-
|
# -*- 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,
|
||||||
|
)
|
||||||
|
@ -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)
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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 %}
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -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'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
23
InvenTree/label/migrations/0007_auto_20210513_1327.py
Normal file
23
InvenTree/label/migrations/0007_auto_20210513_1327.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -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 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>
|
||||||
|
@ -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>
|
||||||
|
@ -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'),
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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 %}
|
||||||
|
@ -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'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
38
InvenTree/report/migrations/0016_auto_20210513_1303.py
Normal file
38
InvenTree/report/migrations/0016_auto_20210513_1303.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
1
InvenTree/templates/mail.html
Normal file
1
InvenTree/templates/mail.html
Normal file
@ -0,0 +1 @@
|
|||||||
|
<a href="mailto:{{ mail }}">{{ mail }}</a>{% include "clip.html"%}
|
1
InvenTree/templates/tel.html
Normal file
1
InvenTree/templates/tel.html
Normal file
@ -0,0 +1 @@
|
|||||||
|
<a href="tel:{{ tel }}">{{ tel }}</a>{% include "clip.html"%}
|
5
InvenTree/templates/version.html
Normal file
5
InvenTree/templates/version.html
Normal 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 %}
|
@ -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
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
|
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
|
||||||
|
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
|
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
|
||||||
|
Loading…
Reference in New Issue
Block a user