mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-11-04 07:05:41 +00:00 
			
		
		
		
	Merge remote-tracking branch 'inventree/master'
This commit is contained in:
		
							
								
								
									
										1
									
								
								.github/workflows/docker_build.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/workflows/docker_build.yaml
									
									
									
									
										vendored
									
									
								
							@@ -30,6 +30,7 @@ jobs:
 | 
			
		||||
          context: ./docker
 | 
			
		||||
          platforms: linux/amd64,linux/arm64,linux/arm/v7
 | 
			
		||||
          push: true
 | 
			
		||||
          target: production
 | 
			
		||||
          repository: inventree/inventree
 | 
			
		||||
          tags: inventree/inventree:latest
 | 
			
		||||
      - name: Image Digest
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1
									
								
								.github/workflows/docker_publish.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/workflows/docker_publish.yaml
									
									
									
									
										vendored
									
									
								
							@@ -28,4 +28,5 @@ jobs:
 | 
			
		||||
          repository: inventree/inventree
 | 
			
		||||
          tag_with_ref: true
 | 
			
		||||
          dockerfile: ./Dockerfile
 | 
			
		||||
          target: production
 | 
			
		||||
          platforms: linux/amd64,linux/arm64,linux/arm/v7
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -31,6 +31,7 @@ var/
 | 
			
		||||
*.log
 | 
			
		||||
local_settings.py
 | 
			
		||||
*.sqlite3
 | 
			
		||||
*.sqlite3-journal
 | 
			
		||||
*.backup
 | 
			
		||||
*.old
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -263,6 +263,7 @@ INSTALLED_APPS = [
 | 
			
		||||
    'djmoney.contrib.exchange',             # django-money exchange rates
 | 
			
		||||
    'error_report',                         # Error reporting in the admin interface
 | 
			
		||||
    'django_q',
 | 
			
		||||
    'formtools',                            # Form wizard tools
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
MIDDLEWARE = CONFIG.get('middleware', [
 | 
			
		||||
@@ -430,11 +431,15 @@ It can be specified in config.yaml (or envvar) as either (for example):
 | 
			
		||||
- django.db.backends.postgresql
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
db_engine = db_config['ENGINE']
 | 
			
		||||
db_engine = db_config['ENGINE'].lower()
 | 
			
		||||
 | 
			
		||||
if db_engine.lower() in ['sqlite3', 'postgresql', 'mysql']:
 | 
			
		||||
# Correct common misspelling
 | 
			
		||||
if db_engine == 'sqlite':
 | 
			
		||||
    db_engine = 'sqlite3'
 | 
			
		||||
 | 
			
		||||
if db_engine in ['sqlite3', 'postgresql', 'mysql']:
 | 
			
		||||
    # Prepend the required python module string
 | 
			
		||||
    db_engine = f'django.db.backends.{db_engine.lower()}'
 | 
			
		||||
    db_engine = f'django.db.backends.{db_engine}'
 | 
			
		||||
    db_config['ENGINE'] = db_engine
 | 
			
		||||
 | 
			
		||||
db_name = db_config['NAME']
 | 
			
		||||
 
 | 
			
		||||
@@ -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, {
 | 
			
		||||
        text: function(trigger) {
 | 
			
		||||
            var content = trigger.parentElement.parentElement.textContent;
 | 
			
		||||
 | 
			
		||||
            return content.trim();
 | 
			
		||||
    // set text-function
 | 
			
		||||
    if (textElement){
 | 
			
		||||
        text = function() {
 | 
			
		||||
            return document.getElementById(textElement).textContent;
 | 
			
		||||
        }
 | 
			
		||||
    } else {
 | 
			
		||||
        text = function() {
 | 
			
		||||
            var content = trigger.parentElement.parentElement.textContent;return content.trim();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // create Clipboard
 | 
			
		||||
    var cis = new ClipboardJS(selector, {
 | 
			
		||||
        text: text,
 | 
			
		||||
        container: containerselector
 | 
			
		||||
    });
 | 
			
		||||
    console.log(cis);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
function inventreeDocReady() {
 | 
			
		||||
    /* Run this function when the HTML document is loaded.
 | 
			
		||||
     * This will be called for every page that extends "base.html"
 | 
			
		||||
@@ -62,6 +79,8 @@ function inventreeDocReady() {
 | 
			
		||||
 | 
			
		||||
    // Initialize clipboard-buttons
 | 
			
		||||
    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>
 | 
			
		||||
            <td><span class='fas fa-info'></span></td>
 | 
			
		||||
            <td>{% trans "Description" %}</td>
 | 
			
		||||
            <td>{{ build.title }}</td>
 | 
			
		||||
            <td>{{ build.title }}{% include "clip.html"%}</td>
 | 
			
		||||
        </tr>
 | 
			
		||||
        <tr>
 | 
			
		||||
            <td><span class='fas fa-shapes'></span></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>
 | 
			
		||||
            <td></td>
 | 
			
		||||
@@ -35,7 +35,7 @@
 | 
			
		||||
            <td>{% trans "Stock Source" %}</td>
 | 
			
		||||
            <td>
 | 
			
		||||
                {% 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 %}
 | 
			
		||||
                <i>{% trans "Stock can be taken from any available location." %}</i>
 | 
			
		||||
                {% endif %}
 | 
			
		||||
@@ -48,7 +48,7 @@
 | 
			
		||||
                {% if build.destination %}
 | 
			
		||||
                <a href="{% url 'stock-location-detail' build.destination.id %}">
 | 
			
		||||
                    {{ build.destination }}
 | 
			
		||||
                </a>
 | 
			
		||||
                </a>{% include "clip.html"%}
 | 
			
		||||
                {% else %}
 | 
			
		||||
                <i>{% trans "Destination location not specified" %}</i>
 | 
			
		||||
                {% endif %}
 | 
			
		||||
@@ -68,28 +68,28 @@
 | 
			
		||||
        <tr>
 | 
			
		||||
            <td><span class='fas fa-layer-group'></span></td>
 | 
			
		||||
            <td>{% trans "Batch" %}</td>
 | 
			
		||||
            <td>{{ build.batch }}</td>
 | 
			
		||||
            <td>{{ build.batch }}{% include "clip.html"%}</td>
 | 
			
		||||
        </tr>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
        {% if build.parent %}
 | 
			
		||||
        <tr>
 | 
			
		||||
            <td><span class='fas fa-sitemap'></span></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>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
        {% if build.sales_order %}
 | 
			
		||||
        <tr>
 | 
			
		||||
            <td><span class='fas fa-dolly'></span></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>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
        {% if build.link %}
 | 
			
		||||
        <tr>
 | 
			
		||||
            <td><span class='fas fa-link'></span></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>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
        {% 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 -*-
 | 
			
		||||
from __future__ import unicode_literals
 | 
			
		||||
 | 
			
		||||
from decimal import Decimal, InvalidOperation
 | 
			
		||||
 | 
			
		||||
from django import forms
 | 
			
		||||
from django.utils.translation import gettext as _
 | 
			
		||||
 | 
			
		||||
from djmoney.forms.fields import MoneyField
 | 
			
		||||
 | 
			
		||||
from InvenTree.forms import HelperForm
 | 
			
		||||
 | 
			
		||||
from .files import FileManager
 | 
			
		||||
from .models import InvenTreeSetting
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -21,3 +29,183 @@ class SettingEditForm(HelperForm):
 | 
			
		||||
        fields = [
 | 
			
		||||
            'value'
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UploadFile(forms.Form):
 | 
			
		||||
    """ Step 1 of FileManagementFormView """
 | 
			
		||||
 | 
			
		||||
    file = forms.FileField(
 | 
			
		||||
        label=_('File'),
 | 
			
		||||
        help_text=_('Select file to upload'),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    def __init__(self, *args, **kwargs):
 | 
			
		||||
        """ Update label and help_text """
 | 
			
		||||
 | 
			
		||||
        # Get file name
 | 
			
		||||
        name = None
 | 
			
		||||
        if 'name' in kwargs:
 | 
			
		||||
            name = kwargs.pop('name')
 | 
			
		||||
 | 
			
		||||
        super().__init__(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
        if name:
 | 
			
		||||
            # Update label and help_text with file name
 | 
			
		||||
            self.fields['file'].label = _(f'{name.title()} File')
 | 
			
		||||
            self.fields['file'].help_text = _(f'Select {name} file to upload')
 | 
			
		||||
 | 
			
		||||
    def clean_file(self):
 | 
			
		||||
        """
 | 
			
		||||
            Run tabular file validation.
 | 
			
		||||
            If anything is wrong with the file, it will raise ValidationError
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        file = self.cleaned_data['file']
 | 
			
		||||
 | 
			
		||||
        # Validate file using FileManager class - will perform initial data validation
 | 
			
		||||
        # (and raise a ValidationError if there is something wrong with the file)
 | 
			
		||||
        FileManager.validate(file)
 | 
			
		||||
 | 
			
		||||
        return file
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MatchField(forms.Form):
 | 
			
		||||
    """ Step 2 of FileManagementFormView """
 | 
			
		||||
    
 | 
			
		||||
    def __init__(self, *args, **kwargs):
 | 
			
		||||
 | 
			
		||||
        # Get FileManager
 | 
			
		||||
        file_manager = None
 | 
			
		||||
        if 'file_manager' in kwargs:
 | 
			
		||||
            file_manager = kwargs.pop('file_manager')
 | 
			
		||||
 | 
			
		||||
        super().__init__(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
        # Setup FileManager
 | 
			
		||||
        file_manager.setup()
 | 
			
		||||
        # Get columns
 | 
			
		||||
        columns = file_manager.columns()
 | 
			
		||||
        # Get headers choices
 | 
			
		||||
        headers_choices = [(header, header) for header in file_manager.HEADERS]
 | 
			
		||||
        
 | 
			
		||||
        # Create column fields
 | 
			
		||||
        for col in columns:
 | 
			
		||||
            field_name = col['name']
 | 
			
		||||
            self.fields[field_name] = forms.ChoiceField(
 | 
			
		||||
                choices=[('', '-' * 10)] + headers_choices,
 | 
			
		||||
                required=False,
 | 
			
		||||
                widget=forms.Select(attrs={
 | 
			
		||||
                    'class': 'select fieldselect',
 | 
			
		||||
                })
 | 
			
		||||
            )
 | 
			
		||||
            if col['guess']:
 | 
			
		||||
                self.fields[field_name].initial = col['guess']
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MatchItem(forms.Form):
 | 
			
		||||
    """ Step 3 of FileManagementFormView """
 | 
			
		||||
    
 | 
			
		||||
    def __init__(self, *args, **kwargs):
 | 
			
		||||
 | 
			
		||||
        # Get FileManager
 | 
			
		||||
        file_manager = None
 | 
			
		||||
        if 'file_manager' in kwargs:
 | 
			
		||||
            file_manager = kwargs.pop('file_manager')
 | 
			
		||||
 | 
			
		||||
        if 'row_data' in kwargs:
 | 
			
		||||
            row_data = kwargs.pop('row_data')
 | 
			
		||||
        else:
 | 
			
		||||
            row_data = None
 | 
			
		||||
 | 
			
		||||
        super().__init__(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
        def clean(number):
 | 
			
		||||
            """ Clean-up decimal value """
 | 
			
		||||
 | 
			
		||||
            # Check if empty
 | 
			
		||||
            if not number:
 | 
			
		||||
                return number
 | 
			
		||||
 | 
			
		||||
            # Check if decimal type
 | 
			
		||||
            try:
 | 
			
		||||
                clean_number = Decimal(number)
 | 
			
		||||
            except InvalidOperation:
 | 
			
		||||
                clean_number = number
 | 
			
		||||
 | 
			
		||||
            return clean_number.quantize(Decimal(1)) if clean_number == clean_number.to_integral() else clean_number.normalize()
 | 
			
		||||
 | 
			
		||||
        # Setup FileManager
 | 
			
		||||
        file_manager.setup()
 | 
			
		||||
 | 
			
		||||
        # Create fields
 | 
			
		||||
        if row_data:
 | 
			
		||||
            # Navigate row data
 | 
			
		||||
            for row in row_data:
 | 
			
		||||
                # Navigate column data
 | 
			
		||||
                for col in row['data']:
 | 
			
		||||
                    # Get column matching
 | 
			
		||||
                    col_guess = col['column'].get('guess', None)
 | 
			
		||||
 | 
			
		||||
                    # Create input for required headers
 | 
			
		||||
                    if col_guess in file_manager.REQUIRED_HEADERS:
 | 
			
		||||
                        # Set field name
 | 
			
		||||
                        field_name = col_guess.lower() + '-' + str(row['index'])
 | 
			
		||||
                        # Set field input box
 | 
			
		||||
                        if 'quantity' in col_guess.lower():
 | 
			
		||||
                            self.fields[field_name] = forms.CharField(
 | 
			
		||||
                                required=False,
 | 
			
		||||
                                widget=forms.NumberInput(attrs={
 | 
			
		||||
                                    'name': 'quantity' + str(row['index']),
 | 
			
		||||
                                    'class': 'numberinput',  # form-control',
 | 
			
		||||
                                    'type': 'number',
 | 
			
		||||
                                    'min': '0',
 | 
			
		||||
                                    'step': 'any',
 | 
			
		||||
                                    'value': clean(row.get('quantity', '')),
 | 
			
		||||
                                })
 | 
			
		||||
                            )
 | 
			
		||||
 | 
			
		||||
                    # Create item selection box
 | 
			
		||||
                    elif col_guess in file_manager.ITEM_MATCH_HEADERS:
 | 
			
		||||
                        # Get item options
 | 
			
		||||
                        item_options = [(option.id, option) for option in row['item_options']]
 | 
			
		||||
                        # Get item match
 | 
			
		||||
                        item_match = row['item_match']
 | 
			
		||||
                        # Set field name
 | 
			
		||||
                        field_name = 'item_select-' + str(row['index'])
 | 
			
		||||
                        # Set field select box
 | 
			
		||||
                        self.fields[field_name] = forms.ChoiceField(
 | 
			
		||||
                            choices=[('', '-' * 10)] + item_options,
 | 
			
		||||
                            required=False,
 | 
			
		||||
                            widget=forms.Select(attrs={
 | 
			
		||||
                                'class': 'select bomselect',
 | 
			
		||||
                            })
 | 
			
		||||
                        )
 | 
			
		||||
                        # Update select box when match was found
 | 
			
		||||
                        if item_match:
 | 
			
		||||
                            # Make it a required field: does not validate if
 | 
			
		||||
                            # removed using JS function
 | 
			
		||||
                            # self.fields[field_name].required = True
 | 
			
		||||
                            # Update initial value
 | 
			
		||||
                            self.fields[field_name].initial = item_match.id
 | 
			
		||||
 | 
			
		||||
                    # Optional entries
 | 
			
		||||
                    elif col_guess in file_manager.OPTIONAL_HEADERS:
 | 
			
		||||
                        # Set field name
 | 
			
		||||
                        field_name = col_guess.lower() + '-' + str(row['index'])
 | 
			
		||||
                        # Get value
 | 
			
		||||
                        value = row.get(col_guess.lower(), '')
 | 
			
		||||
                        # Set field input box
 | 
			
		||||
                        if 'price' in col_guess.lower():
 | 
			
		||||
                            self.fields[field_name] = MoneyField(
 | 
			
		||||
                                label=_(col_guess),
 | 
			
		||||
                                default_currency=InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY'),
 | 
			
		||||
                                decimal_places=5,
 | 
			
		||||
                                max_digits=19,
 | 
			
		||||
                                required=False,
 | 
			
		||||
                                default_amount=clean(value),
 | 
			
		||||
                            )
 | 
			
		||||
                        else:
 | 
			
		||||
                            self.fields[field_name] = forms.CharField(
 | 
			
		||||
                                required=False,
 | 
			
		||||
                                initial=value,
 | 
			
		||||
                            )
 | 
			
		||||
 
 | 
			
		||||
@@ -5,14 +5,21 @@ Django views for interacting with common models
 | 
			
		||||
# -*- coding: utf-8 -*-
 | 
			
		||||
from __future__ import unicode_literals
 | 
			
		||||
 | 
			
		||||
import os
 | 
			
		||||
 | 
			
		||||
from django.utils.translation import ugettext_lazy as _
 | 
			
		||||
from django.forms import CheckboxInput, Select
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.core.files.storage import FileSystemStorage
 | 
			
		||||
 | 
			
		||||
from formtools.wizard.views import SessionWizardView
 | 
			
		||||
 | 
			
		||||
from InvenTree.views import AjaxUpdateView
 | 
			
		||||
from InvenTree.helpers import str2bool
 | 
			
		||||
 | 
			
		||||
from . import models
 | 
			
		||||
from . import forms
 | 
			
		||||
from .files import FileManager
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SettingEdit(AjaxUpdateView):
 | 
			
		||||
@@ -101,3 +108,392 @@ class SettingEdit(AjaxUpdateView):
 | 
			
		||||
 | 
			
		||||
            if not str2bool(value, test=True) and not str2bool(value, test=False):
 | 
			
		||||
                form.add_error('value', _('Supplied value must be a boolean'))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MultiStepFormView(SessionWizardView):
 | 
			
		||||
    """ Setup basic methods of multi-step form
 | 
			
		||||
 | 
			
		||||
        form_list: list of forms
 | 
			
		||||
        form_steps_description: description for each form
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    form_list = []
 | 
			
		||||
    form_steps_template = []
 | 
			
		||||
    form_steps_description = []
 | 
			
		||||
    file_manager = None
 | 
			
		||||
    media_folder = ''
 | 
			
		||||
    file_storage = FileSystemStorage(settings.MEDIA_ROOT)
 | 
			
		||||
 | 
			
		||||
    def __init__(self, *args, **kwargs):
 | 
			
		||||
        """ Override init method to set media folder """
 | 
			
		||||
        super().__init__(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
        self.process_media_folder()
 | 
			
		||||
        
 | 
			
		||||
    def process_media_folder(self):
 | 
			
		||||
        """ Process media folder """
 | 
			
		||||
 | 
			
		||||
        if self.media_folder:
 | 
			
		||||
            media_folder_abs = os.path.join(settings.MEDIA_ROOT, self.media_folder)
 | 
			
		||||
            if not os.path.exists(media_folder_abs):
 | 
			
		||||
                os.mkdir(media_folder_abs)
 | 
			
		||||
            self.file_storage = FileSystemStorage(location=media_folder_abs)
 | 
			
		||||
 | 
			
		||||
    def get_template_names(self):
 | 
			
		||||
        """ Select template """
 | 
			
		||||
        
 | 
			
		||||
        try:
 | 
			
		||||
            # Get template
 | 
			
		||||
            template = self.form_steps_template[self.steps.index]
 | 
			
		||||
        except IndexError:
 | 
			
		||||
            return self.template_name
 | 
			
		||||
 | 
			
		||||
        return template
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        """ Update context data """
 | 
			
		||||
        
 | 
			
		||||
        # Retrieve current context
 | 
			
		||||
        context = super().get_context_data(**kwargs)
 | 
			
		||||
 | 
			
		||||
        # Get form description
 | 
			
		||||
        try:
 | 
			
		||||
            description = self.form_steps_description[self.steps.index]
 | 
			
		||||
        except IndexError:
 | 
			
		||||
            description = ''
 | 
			
		||||
        # Add description to form steps
 | 
			
		||||
        context.update({'description': description})
 | 
			
		||||
 | 
			
		||||
        return context
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class FileManagementFormView(MultiStepFormView):
 | 
			
		||||
    """ Setup form wizard to perform the following steps:
 | 
			
		||||
        1. Upload tabular data file
 | 
			
		||||
        2. Match headers to InvenTree fields
 | 
			
		||||
        3. Edit row data and match InvenTree items
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    name = None
 | 
			
		||||
    form_list = [
 | 
			
		||||
        ('upload', forms.UploadFile),
 | 
			
		||||
        ('fields', forms.MatchField),
 | 
			
		||||
        ('items', forms.MatchItem),
 | 
			
		||||
    ]
 | 
			
		||||
    form_steps_description = [
 | 
			
		||||
        _("Upload File"),
 | 
			
		||||
        _("Match Fields"),
 | 
			
		||||
        _("Match Items"),
 | 
			
		||||
    ]
 | 
			
		||||
    media_folder = 'file_upload/'
 | 
			
		||||
    extra_context_data = {}
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, form, **kwargs):
 | 
			
		||||
        context = super().get_context_data(form=form, **kwargs)
 | 
			
		||||
 | 
			
		||||
        if self.steps.current in ('fields', 'items'):
 | 
			
		||||
            
 | 
			
		||||
            # Get columns and row data
 | 
			
		||||
            self.columns = self.file_manager.columns()
 | 
			
		||||
            self.rows = self.file_manager.rows()
 | 
			
		||||
            # Check for stored data
 | 
			
		||||
            stored_data = self.storage.get_step_data(self.steps.current)
 | 
			
		||||
            if stored_data:
 | 
			
		||||
                self.get_form_table_data(stored_data)
 | 
			
		||||
            elif self.steps.current == 'items':
 | 
			
		||||
                # Set form table data
 | 
			
		||||
                self.set_form_table_data(form=form)
 | 
			
		||||
            
 | 
			
		||||
            # Update context
 | 
			
		||||
            context.update({'rows': self.rows})
 | 
			
		||||
            context.update({'columns': self.columns})
 | 
			
		||||
 | 
			
		||||
        # Load extra context data
 | 
			
		||||
        for key, items in self.extra_context_data.items():
 | 
			
		||||
            context.update({key: items})
 | 
			
		||||
 | 
			
		||||
        return context
 | 
			
		||||
 | 
			
		||||
    def get_file_manager(self, step=None, form=None):
 | 
			
		||||
        """ Get FileManager instance from uploaded file """
 | 
			
		||||
 | 
			
		||||
        if self.file_manager:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        if step is not None:
 | 
			
		||||
            # Retrieve stored files from upload step
 | 
			
		||||
            upload_files = self.storage.get_step_files('upload')
 | 
			
		||||
            if upload_files:
 | 
			
		||||
                # Get file
 | 
			
		||||
                file = upload_files.get('upload-file', None)
 | 
			
		||||
                if file:
 | 
			
		||||
                    self.file_manager = FileManager(file=file, name=self.name)
 | 
			
		||||
 | 
			
		||||
    def get_form_kwargs(self, step=None):
 | 
			
		||||
        """ Update kwargs to dynamically build forms """
 | 
			
		||||
 | 
			
		||||
        # Always retrieve FileManager instance from uploaded file
 | 
			
		||||
        self.get_file_manager(step)
 | 
			
		||||
 | 
			
		||||
        if step == 'upload':
 | 
			
		||||
            # Dynamically build upload form
 | 
			
		||||
            if self.name:
 | 
			
		||||
                kwargs = {
 | 
			
		||||
                    'name': self.name
 | 
			
		||||
                }
 | 
			
		||||
                return kwargs
 | 
			
		||||
        elif step == 'fields':
 | 
			
		||||
            # Dynamically build match field form
 | 
			
		||||
            kwargs = {
 | 
			
		||||
                'file_manager': self.file_manager
 | 
			
		||||
            }
 | 
			
		||||
            return kwargs
 | 
			
		||||
        elif step == 'items':
 | 
			
		||||
            # Dynamically build match item form
 | 
			
		||||
            kwargs = {}
 | 
			
		||||
            kwargs['file_manager'] = self.file_manager
 | 
			
		||||
 | 
			
		||||
            # Get data from fields step
 | 
			
		||||
            data = self.storage.get_step_data('fields')
 | 
			
		||||
 | 
			
		||||
            # Process to update columns and rows
 | 
			
		||||
            self.rows = self.file_manager.rows()
 | 
			
		||||
            self.columns = self.file_manager.columns()
 | 
			
		||||
            self.get_form_table_data(data)
 | 
			
		||||
            self.set_form_table_data()
 | 
			
		||||
            self.get_field_selection()
 | 
			
		||||
            
 | 
			
		||||
            kwargs['row_data'] = self.rows
 | 
			
		||||
 | 
			
		||||
            return kwargs
 | 
			
		||||
        
 | 
			
		||||
        return super().get_form_kwargs()
 | 
			
		||||
 | 
			
		||||
    def get_form_table_data(self, form_data):
 | 
			
		||||
        """ Extract table cell data from form data and fields.
 | 
			
		||||
        These data are used to maintain state between sessions.
 | 
			
		||||
 | 
			
		||||
        Table data keys are as follows:
 | 
			
		||||
 | 
			
		||||
            col_name_<idx> - Column name at idx as provided in the uploaded file
 | 
			
		||||
            col_guess_<idx> - Column guess at idx as selected
 | 
			
		||||
            row_<x>_col<y> - Cell data as provided in the uploaded file
 | 
			
		||||
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        # Map the columns
 | 
			
		||||
        self.column_names = {}
 | 
			
		||||
        self.column_selections = {}
 | 
			
		||||
 | 
			
		||||
        self.row_data = {}
 | 
			
		||||
 | 
			
		||||
        for item, value in form_data.items():
 | 
			
		||||
 | 
			
		||||
            # Column names as passed as col_name_<idx> where idx is an integer
 | 
			
		||||
 | 
			
		||||
            # Extract the column names
 | 
			
		||||
            if item.startswith('col_name_'):
 | 
			
		||||
                try:
 | 
			
		||||
                    col_id = int(item.replace('col_name_', ''))
 | 
			
		||||
                except ValueError:
 | 
			
		||||
                    continue
 | 
			
		||||
 | 
			
		||||
                self.column_names[col_id] = value
 | 
			
		||||
 | 
			
		||||
            # Extract the column selections (in the 'select fields' view)
 | 
			
		||||
            if item.startswith('fields-'):
 | 
			
		||||
 | 
			
		||||
                try:
 | 
			
		||||
                    col_name = item.replace('fields-', '')
 | 
			
		||||
                except ValueError:
 | 
			
		||||
                    continue
 | 
			
		||||
 | 
			
		||||
                for idx, name in self.column_names.items():
 | 
			
		||||
                    if name == col_name:
 | 
			
		||||
                        self.column_selections[idx] = value
 | 
			
		||||
                        break
 | 
			
		||||
 | 
			
		||||
            # Extract the row data
 | 
			
		||||
            if item.startswith('row_'):
 | 
			
		||||
                # Item should be of the format row_<r>_col_<c>
 | 
			
		||||
                s = item.split('_')
 | 
			
		||||
 | 
			
		||||
                if len(s) < 4:
 | 
			
		||||
                    continue
 | 
			
		||||
 | 
			
		||||
                # Ignore row/col IDs which are not correct numeric values
 | 
			
		||||
                try:
 | 
			
		||||
                    row_id = int(s[1])
 | 
			
		||||
                    col_id = int(s[3])
 | 
			
		||||
                except ValueError:
 | 
			
		||||
                    continue
 | 
			
		||||
                
 | 
			
		||||
                if row_id not in self.row_data:
 | 
			
		||||
                    self.row_data[row_id] = {}
 | 
			
		||||
 | 
			
		||||
                self.row_data[row_id][col_id] = value
 | 
			
		||||
 | 
			
		||||
    def set_form_table_data(self, form=None):
 | 
			
		||||
        """ Set the form table data """
 | 
			
		||||
 | 
			
		||||
        if self.column_names:
 | 
			
		||||
            # Re-construct the column data
 | 
			
		||||
            self.columns = []
 | 
			
		||||
 | 
			
		||||
            for idx, value in self.column_names.items():
 | 
			
		||||
                header = ({
 | 
			
		||||
                    'name': value,
 | 
			
		||||
                    'guess': self.column_selections.get(idx, ''),
 | 
			
		||||
                })
 | 
			
		||||
                self.columns.append(header)
 | 
			
		||||
 | 
			
		||||
        if self.row_data:
 | 
			
		||||
            # Re-construct the row data
 | 
			
		||||
            self.rows = []
 | 
			
		||||
 | 
			
		||||
            # Update the row data
 | 
			
		||||
            for row_idx, row_key in enumerate(sorted(self.row_data.keys())):
 | 
			
		||||
                row_data = self.row_data[row_key]
 | 
			
		||||
 | 
			
		||||
                data = []
 | 
			
		||||
 | 
			
		||||
                for idx, item in row_data.items():
 | 
			
		||||
                    column_data = {
 | 
			
		||||
                        'name': self.column_names[idx],
 | 
			
		||||
                        'guess': self.column_selections[idx],
 | 
			
		||||
                    }
 | 
			
		||||
                    
 | 
			
		||||
                    cell_data = {
 | 
			
		||||
                        'cell': item,
 | 
			
		||||
                        'idx': idx,
 | 
			
		||||
                        'column': column_data,
 | 
			
		||||
                    }
 | 
			
		||||
                    data.append(cell_data)
 | 
			
		||||
                
 | 
			
		||||
                row = {
 | 
			
		||||
                    'index': row_idx,
 | 
			
		||||
                    'data': data,
 | 
			
		||||
                    'errors': {},
 | 
			
		||||
                }
 | 
			
		||||
                self.rows.append(row)
 | 
			
		||||
 | 
			
		||||
        # In the item selection step: update row data with mapping to form fields
 | 
			
		||||
        if form and self.steps.current == 'items':
 | 
			
		||||
            # Find field keys
 | 
			
		||||
            field_keys = []
 | 
			
		||||
            for field in form.fields:
 | 
			
		||||
                field_key = field.split('-')[0]
 | 
			
		||||
                if field_key not in field_keys:
 | 
			
		||||
                    field_keys.append(field_key)
 | 
			
		||||
 | 
			
		||||
            # Populate rows
 | 
			
		||||
            for row in self.rows:
 | 
			
		||||
                for field_key in field_keys:
 | 
			
		||||
                    # Map row data to field
 | 
			
		||||
                    row[field_key] = field_key + '-' + str(row['index'])
 | 
			
		||||
 | 
			
		||||
    def get_column_index(self, name):
 | 
			
		||||
        """ Return the index of the column with the given name.
 | 
			
		||||
        It named column is not found, return -1
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            idx = list(self.column_selections.values()).index(name)
 | 
			
		||||
        except ValueError:
 | 
			
		||||
            idx = -1
 | 
			
		||||
 | 
			
		||||
        return idx
 | 
			
		||||
 | 
			
		||||
    def get_field_selection(self):
 | 
			
		||||
        """ Once data columns have been selected, attempt to pre-select the proper data from the database.
 | 
			
		||||
        This function is called once the field selection has been validated.
 | 
			
		||||
        The pre-fill data are then passed through to the part selection form.
 | 
			
		||||
 | 
			
		||||
        This method is very specific to the type of data found in the file,
 | 
			
		||||
        therefore overwrite it in the subclass.
 | 
			
		||||
        """
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
    def check_field_selection(self, form):
 | 
			
		||||
        """ Check field matching """
 | 
			
		||||
 | 
			
		||||
        # Are there any missing columns?
 | 
			
		||||
        missing_columns = []
 | 
			
		||||
 | 
			
		||||
        # Check that all required fields are present
 | 
			
		||||
        for col in self.file_manager.REQUIRED_HEADERS:
 | 
			
		||||
            if col not in self.column_selections.values():
 | 
			
		||||
                missing_columns.append(col)
 | 
			
		||||
 | 
			
		||||
        # Check that at least one of the part match field is present
 | 
			
		||||
        part_match_found = False
 | 
			
		||||
        for col in self.file_manager.ITEM_MATCH_HEADERS:
 | 
			
		||||
            if col in self.column_selections.values():
 | 
			
		||||
                part_match_found = True
 | 
			
		||||
                break
 | 
			
		||||
        
 | 
			
		||||
        # If not, notify user
 | 
			
		||||
        if not part_match_found:
 | 
			
		||||
            for col in self.file_manager.ITEM_MATCH_HEADERS:
 | 
			
		||||
                missing_columns.append(col)
 | 
			
		||||
 | 
			
		||||
        # Track any duplicate column selections
 | 
			
		||||
        duplicates = []
 | 
			
		||||
 | 
			
		||||
        for col in self.column_names:
 | 
			
		||||
 | 
			
		||||
            if col in self.column_selections:
 | 
			
		||||
                guess = self.column_selections[col]
 | 
			
		||||
            else:
 | 
			
		||||
                guess = None
 | 
			
		||||
 | 
			
		||||
            if guess:
 | 
			
		||||
                n = list(self.column_selections.values()).count(self.column_selections[col])
 | 
			
		||||
                if n > 1:
 | 
			
		||||
                    duplicates.append(col)
 | 
			
		||||
        
 | 
			
		||||
        # Store extra context data
 | 
			
		||||
        self.extra_context_data = {
 | 
			
		||||
            'missing_columns': missing_columns,
 | 
			
		||||
            'duplicates': duplicates,
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        # Data validation
 | 
			
		||||
        valid = not missing_columns and not duplicates
 | 
			
		||||
 | 
			
		||||
        return valid
 | 
			
		||||
 | 
			
		||||
    def validate(self, step, form):
 | 
			
		||||
        """ Validate forms """
 | 
			
		||||
 | 
			
		||||
        valid = True
 | 
			
		||||
 | 
			
		||||
        # Get form table data
 | 
			
		||||
        self.get_form_table_data(form.data)
 | 
			
		||||
 | 
			
		||||
        if step == 'fields':
 | 
			
		||||
            # Validate user form data
 | 
			
		||||
            valid = self.check_field_selection(form)
 | 
			
		||||
 | 
			
		||||
            if not valid:
 | 
			
		||||
                form.add_error(None, _('Fields matching failed'))
 | 
			
		||||
 | 
			
		||||
        elif step == 'items':
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
        return valid
 | 
			
		||||
 | 
			
		||||
    def post(self, request, *args, **kwargs):
 | 
			
		||||
        """ Perform validations before posting data """
 | 
			
		||||
 | 
			
		||||
        wizard_goto_step = self.request.POST.get('wizard_goto_step', None)
 | 
			
		||||
 | 
			
		||||
        form = self.get_form(data=self.request.POST, files=self.request.FILES)
 | 
			
		||||
 | 
			
		||||
        form_valid = self.validate(self.steps.current, form)
 | 
			
		||||
 | 
			
		||||
        if not form_valid and not wizard_goto_step:
 | 
			
		||||
            # Re-render same step
 | 
			
		||||
            return self.render(form)
 | 
			
		||||
 | 
			
		||||
        return super().post(*args, **kwargs)
 | 
			
		||||
 
 | 
			
		||||
@@ -68,35 +68,35 @@
 | 
			
		||||
<tr>
 | 
			
		||||
    <td><span class='fas fa-globe'></span></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>
 | 
			
		||||
{% endif %}
 | 
			
		||||
{% if company.address %}
 | 
			
		||||
<tr>
 | 
			
		||||
    <td><span class='fas fa-map-marked-alt'></span></td>
 | 
			
		||||
    <td>{% trans "Address" %}</td>
 | 
			
		||||
    <td>{{ company.address }}</td>
 | 
			
		||||
    <td>{{ company.address }}{% include "clip.html"%}</td>
 | 
			
		||||
</tr>
 | 
			
		||||
{% endif %}
 | 
			
		||||
{% if company.phone %}
 | 
			
		||||
<tr>
 | 
			
		||||
    <td><span class='fas fa-phone'></span></td>
 | 
			
		||||
    <td>{% trans "Phone" %}</td>
 | 
			
		||||
    <td>{{ company.phone }}</td>
 | 
			
		||||
    <td>{% include "tel.html" with tel=company.phone %}</td>
 | 
			
		||||
</tr>
 | 
			
		||||
{% endif %}
 | 
			
		||||
{% if company.email %}
 | 
			
		||||
<tr>
 | 
			
		||||
    <td><span class='fas fa-at'></span></td>
 | 
			
		||||
    <td>{% trans "Email" %}</td>
 | 
			
		||||
    <td>{{ company.email }}</td>
 | 
			
		||||
    <td>{% include "mail.html" with mail=company.email %}</td>
 | 
			
		||||
</tr>
 | 
			
		||||
{% endif %}
 | 
			
		||||
{% if company.contact %}
 | 
			
		||||
<tr>
 | 
			
		||||
    <td><span class='fas fa-user'></span></td>
 | 
			
		||||
    <td>{% trans "Contact" %}</td>
 | 
			
		||||
    <td>{{ company.contact }}</td>
 | 
			
		||||
    <td>{{ company.contact }}{% include "clip.html"%}</td>
 | 
			
		||||
</tr>
 | 
			
		||||
{% endif %}
 | 
			
		||||
</table>
 | 
			
		||||
 
 | 
			
		||||
@@ -19,20 +19,20 @@
 | 
			
		||||
            <tr>
 | 
			
		||||
                <td><span class='fas fa-font'></span></td>
 | 
			
		||||
                <td>{% trans "Company Name" %}</td>
 | 
			
		||||
                <td>{{ company.name }}</td>
 | 
			
		||||
                <td>{{ company.name }}{% include "clip.html"%}</td>
 | 
			
		||||
            </tr>
 | 
			
		||||
            {% if company.description %}
 | 
			
		||||
            <tr>
 | 
			
		||||
                <td><span class='fas fa-info'></span></td>
 | 
			
		||||
                <td>{% trans "Description" %}</td>
 | 
			
		||||
                <td>{{ company.description }}</td>
 | 
			
		||||
                <td>{{ company.description }}{% include "clip.html"%}</td>
 | 
			
		||||
            </tr>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
            <tr>
 | 
			
		||||
                <td><span class='fas fa-globe'></span></td>
 | 
			
		||||
                <td>{% trans "Website" %}</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>
 | 
			
		||||
                    {% endif %}
 | 
			
		||||
                </td>
 | 
			
		||||
 
 | 
			
		||||
@@ -62,7 +62,7 @@ src="{% static 'img/blank_image.png' %}"
 | 
			
		||||
            <td>{% trans "Internal Part" %}</td>
 | 
			
		||||
            <td>
 | 
			
		||||
                {% 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 %}
 | 
			
		||||
            </td>
 | 
			
		||||
        </tr>
 | 
			
		||||
@@ -70,24 +70,24 @@ src="{% static 'img/blank_image.png' %}"
 | 
			
		||||
        <tr>
 | 
			
		||||
            <td></td>
 | 
			
		||||
            <td>{% trans "Description" %}</td>
 | 
			
		||||
            <td>{{ part.description }}</td>
 | 
			
		||||
            <td>{{ part.description }}{% include "clip.html"%}</td>
 | 
			
		||||
        </tr>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
        {% if part.link %}
 | 
			
		||||
        <tr>
 | 
			
		||||
            <td><span class='fas fa-link'></span></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>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
        <tr>
 | 
			
		||||
            <td><span class='fas fa-industry'></span></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>
 | 
			
		||||
            <td><span class='fas fa-hashtag'></span></td>
 | 
			
		||||
            <td>{% trans "MPN" %}</td>
 | 
			
		||||
            <td>{{ part.MPN }}</td>
 | 
			
		||||
            <td>{{ part.MPN }}{% include "clip.html"%}</td>
 | 
			
		||||
        </tr>
 | 
			
		||||
</table>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 
 | 
			
		||||
@@ -61,7 +61,7 @@ src="{% static 'img/blank_image.png' %}"
 | 
			
		||||
            <td>{% trans "Internal Part" %}</td>
 | 
			
		||||
            <td>
 | 
			
		||||
                {% 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 %}
 | 
			
		||||
            </td>
 | 
			
		||||
        </tr>
 | 
			
		||||
@@ -69,51 +69,52 @@ src="{% static 'img/blank_image.png' %}"
 | 
			
		||||
        <tr>
 | 
			
		||||
            <td></td>
 | 
			
		||||
            <td>{% trans "Description" %}</td>
 | 
			
		||||
            <td>{{ part.description }}</td>
 | 
			
		||||
            <td>{{ part.description }}{% include "clip.html"%}</td>
 | 
			
		||||
        </tr>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
        {% if part.link %}
 | 
			
		||||
        <tr>
 | 
			
		||||
            <td><span class='fas fa-link'></span></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>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
        <tr>
 | 
			
		||||
            <td><span class='fas fa-building'></span></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>
 | 
			
		||||
            <td><span class='fas fa-hashtag'></span></td>
 | 
			
		||||
            <td>{% trans "SKU" %}</td>
 | 
			
		||||
            <td>{{ part.SKU }}</tr>
 | 
			
		||||
            <td>{{ part.SKU }}{% include "clip.html"%}</tr>
 | 
			
		||||
        </tr>
 | 
			
		||||
        {% if part.manufacturer_part.manufacturer %}
 | 
			
		||||
        <tr>
 | 
			
		||||
            <td><span class='fas fa-industry'></span></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>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
        {% if part.manufacturer_part.MPN %}
 | 
			
		||||
        <tr>
 | 
			
		||||
            <td><span class='fas fa-hashtag'></span></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>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
        {% if part.packaging %}
 | 
			
		||||
        <tr>
 | 
			
		||||
            <td><span class='fas fa-cube'></span></td>
 | 
			
		||||
            <td>{% trans "Packaging" %}</td>
 | 
			
		||||
            <td>{{ part.packaging }}</td>
 | 
			
		||||
            <td>{{ part.packaging }}{% include "clip.html"%}</td>
 | 
			
		||||
        </tr>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
        {% if part.note %}
 | 
			
		||||
        <tr>
 | 
			
		||||
            <td><span class='fas fa-sticky-note'></span></td>
 | 
			
		||||
            <td>{% trans "Note" %}</td>
 | 
			
		||||
            <td>{{ part.note }}</td>
 | 
			
		||||
            <td>{{ part.note }}{% include "clip.html"%}</td>
 | 
			
		||||
        </tr>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
</table>
 | 
			
		||||
 
 | 
			
		||||
@@ -28,14 +28,14 @@
 | 
			
		||||
    <tr><td>{% trans "External Link" %}</td><td><a href="{{ part.link }}">{{ part.link }}</a></td></tr>
 | 
			
		||||
{% endif %}
 | 
			
		||||
{% 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 %}
 | 
			
		||||
{% if part.manufacturer %}
 | 
			
		||||
    <tr><td>{% trans "Manufacturer" %}</td><td>{{ part.manufacturer }}</td></tr>
 | 
			
		||||
    <tr><td>{% trans "MPN" %}</td><td>{{ part.MPN }}</td></tr>
 | 
			
		||||
    <tr><td>{% trans "Manufacturer" %}</td><td>{{ part.manufacturer }}{% include "clip.html"%}</td></tr>
 | 
			
		||||
    <tr><td>{% trans "MPN" %}</td><td>{{ part.MPN }}{% include "clip.html"%}</td></tr>
 | 
			
		||||
{% endif %}
 | 
			
		||||
{% 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 %}
 | 
			
		||||
</table>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -63,16 +63,23 @@ class LabelPrintMixin:
 | 
			
		||||
        # In debug mode, generate single HTML output, rather than PDF
 | 
			
		||||
        debug_mode = common.models.InvenTreeSetting.get_setting('REPORT_DEBUG_MODE')
 | 
			
		||||
 | 
			
		||||
        label_name = "label.pdf"
 | 
			
		||||
 | 
			
		||||
        # Merge one or more PDF files into a single download
 | 
			
		||||
        for item in items_to_print:
 | 
			
		||||
            label = self.get_object()
 | 
			
		||||
            label.object_to_print = item
 | 
			
		||||
 | 
			
		||||
            label_name = label.generate_filename(request)
 | 
			
		||||
 | 
			
		||||
            if debug_mode:
 | 
			
		||||
                outputs.append(label.render_as_string(request))
 | 
			
		||||
            else:
 | 
			
		||||
                outputs.append(label.render(request))
 | 
			
		||||
 | 
			
		||||
        if not label_name.endswith(".pdf"):
 | 
			
		||||
            label_name += ".pdf"
 | 
			
		||||
 | 
			
		||||
        if debug_mode:
 | 
			
		||||
            """
 | 
			
		||||
            Contatenate all rendered templates into a single HTML string,
 | 
			
		||||
@@ -103,7 +110,7 @@ class LabelPrintMixin:
 | 
			
		||||
 | 
			
		||||
            return InvenTree.helpers.DownloadFile(
 | 
			
		||||
                pdf,
 | 
			
		||||
                'inventree_label.pdf',
 | 
			
		||||
                label_name,
 | 
			
		||||
                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.exceptions import ValidationError, FieldError
 | 
			
		||||
 | 
			
		||||
from django.template import Template, Context
 | 
			
		||||
from django.template.loader import render_to_string
 | 
			
		||||
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
@@ -138,6 +139,13 @@ class LabelTemplate(models.Model):
 | 
			
		||||
        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
 | 
			
		||||
    def template_name(self):
 | 
			
		||||
        """
 | 
			
		||||
@@ -162,6 +170,19 @@ class LabelTemplate(models.Model):
 | 
			
		||||
 | 
			
		||||
        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):
 | 
			
		||||
        """
 | 
			
		||||
        Provides context data to the template.
 | 
			
		||||
@@ -201,6 +222,7 @@ class LabelTemplate(models.Model):
 | 
			
		||||
            self.template_name,
 | 
			
		||||
            base_url=request.build_absolute_uri("/"),
 | 
			
		||||
            presentational_hints=True,
 | 
			
		||||
            filename=self.generate_filename(request),
 | 
			
		||||
            **kwargs
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -33,7 +33,7 @@ src="{% static 'img/blank_image.png' %}"
 | 
			
		||||
    {% endif %}
 | 
			
		||||
</h3>
 | 
			
		||||
<hr>
 | 
			
		||||
<p>{{ order.description }}</p>
 | 
			
		||||
<p>{{ order.description }}{% include "clip.html"%}</p>
 | 
			
		||||
<div class='btn-row'>
 | 
			
		||||
    <div class='btn-group action-buttons' role='group'>
 | 
			
		||||
        <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>
 | 
			
		||||
        <td><span class='fas fa-hashtag'></span></td>
 | 
			
		||||
        <td>{% trans "Order Reference" %}</td>
 | 
			
		||||
        <td>{{ order.reference }}</td>
 | 
			
		||||
        <td>{{ order.reference }}{% include "clip.html"%}</td>
 | 
			
		||||
    </tr>
 | 
			
		||||
    <tr>
 | 
			
		||||
        <td><span class='fas fa-info'></span></td>
 | 
			
		||||
@@ -90,20 +90,20 @@ src="{% static 'img/blank_image.png' %}"
 | 
			
		||||
    <tr>
 | 
			
		||||
        <td><span class='fas fa-building'></span></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>
 | 
			
		||||
    {% if order.supplier_reference %}
 | 
			
		||||
    <tr>
 | 
			
		||||
        <td><span class='fas fa-hashtag'></span></td>
 | 
			
		||||
        <td>{% trans "Supplier Reference" %}</td>
 | 
			
		||||
        <td>{{ order.supplier_reference }}</td>
 | 
			
		||||
        <td>{{ order.supplier_reference }}{% include "clip.html"%}</td>
 | 
			
		||||
    </tr>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
    {% if order.link %}
 | 
			
		||||
    <tr>
 | 
			
		||||
        <td><span class='fas fa-link'></span></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>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
    <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 static %}
 | 
			
		||||
{% load inventree_extras %}
 | 
			
		||||
{% load status_codes %}
 | 
			
		||||
 | 
			
		||||
<ul class='list-group'>
 | 
			
		||||
    <li class='list-group-item'>
 | 
			
		||||
@@ -14,6 +15,14 @@
 | 
			
		||||
            {% trans "Details" %}
 | 
			
		||||
        </a>
 | 
			
		||||
    </li>
 | 
			
		||||
    {% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change %}
 | 
			
		||||
    <li class='list-group-item {% if tab == "upload" %}active{% endif %}' title='{% trans "Upload File" %}'>
 | 
			
		||||
        <a href='{% url "po-upload" order.id %}'>
 | 
			
		||||
            <span class='fas fa-file-upload'></span>
 | 
			
		||||
            {% trans "Upload File" %}
 | 
			
		||||
        </a>
 | 
			
		||||
    </li>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
    <li class='list-group-item {% if tab == "received" %}active{% endif %}' title='{% trans "Received Stock Items" %}'>
 | 
			
		||||
        <a href='{% url "po-received" order.id %}'>
 | 
			
		||||
            <span class='fas fa-sign-in-alt'></span>
 | 
			
		||||
 
 | 
			
		||||
@@ -42,7 +42,7 @@ src="{% static 'img/blank_image.png' %}"
 | 
			
		||||
    {% endif %}
 | 
			
		||||
</h3>
 | 
			
		||||
<hr>
 | 
			
		||||
<p>{{ order.description }}</p>
 | 
			
		||||
<p>{{ order.description }}{% include "clip.html"%}</p>
 | 
			
		||||
<div class='btn-row'>
 | 
			
		||||
    <div class='btn-group action-buttons'>
 | 
			
		||||
        <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>
 | 
			
		||||
        <td><span class='fas fa-hashtag'></span></td>
 | 
			
		||||
        <td>{% trans "Order Reference" %}</td>
 | 
			
		||||
        <td>{{ order.reference }}</td>
 | 
			
		||||
        <td>{{ order.reference }}{% include "clip.html"%}</td>
 | 
			
		||||
    </tr>
 | 
			
		||||
    <tr>
 | 
			
		||||
        <td><span class='fas fa-info'></span></td>
 | 
			
		||||
@@ -90,20 +90,20 @@ src="{% static 'img/blank_image.png' %}"
 | 
			
		||||
    <tr>
 | 
			
		||||
        <td><span class='fas fa-building'></span></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>
 | 
			
		||||
    {% if order.customer_reference %}
 | 
			
		||||
    <tr>
 | 
			
		||||
        <td><span class='fas fa-hashtag'></span></td>
 | 
			
		||||
        <td>{% trans "Customer Reference" %}</td>
 | 
			
		||||
        <td>{{ order.customer_reference }}</td>
 | 
			
		||||
        <td>{{ order.customer_reference }}{% include "clip.html"%}</td>
 | 
			
		||||
    </tr>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
    {% if order.link %}
 | 
			
		||||
    <tr>
 | 
			
		||||
        <td><span class='fas fa-link'></span></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>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
    <tr>
 | 
			
		||||
 
 | 
			
		||||
@@ -17,6 +17,7 @@ purchase_order_detail_urls = [
 | 
			
		||||
    url(r'^receive/', views.PurchaseOrderReceive.as_view(), name='po-receive'),
 | 
			
		||||
    url(r'^complete/', views.PurchaseOrderComplete.as_view(), name='po-complete'),
 | 
			
		||||
 | 
			
		||||
    url(r'^upload/', views.PurchaseOrderUpload.as_view(), name='po-upload'),
 | 
			
		||||
    url(r'^export/', views.PurchaseOrderExport.as_view(), name='po-export'),
 | 
			
		||||
 | 
			
		||||
    url(r'^notes/', views.PurchaseOrderNotes.as_view(), name='po-notes'),
 | 
			
		||||
 
 | 
			
		||||
@@ -6,10 +6,12 @@ Django views for interacting with Order app
 | 
			
		||||
from __future__ import unicode_literals
 | 
			
		||||
 | 
			
		||||
from django.db import transaction
 | 
			
		||||
from django.db.utils import IntegrityError
 | 
			
		||||
from django.http.response import JsonResponse
 | 
			
		||||
from django.shortcuts import get_object_or_404
 | 
			
		||||
from django.core.exceptions import ValidationError
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
from django.http import HttpResponseRedirect
 | 
			
		||||
from django.utils.translation import ugettext_lazy as _
 | 
			
		||||
from django.views.generic import DetailView, ListView, UpdateView
 | 
			
		||||
from django.views.generic.edit import FormMixin
 | 
			
		||||
@@ -23,11 +25,12 @@ from .models import SalesOrder, SalesOrderLineItem, SalesOrderAttachment
 | 
			
		||||
from .models import SalesOrderAllocation
 | 
			
		||||
from .admin import POLineItemResource
 | 
			
		||||
from build.models import Build
 | 
			
		||||
from company.models import Company, SupplierPart
 | 
			
		||||
from company.models import Company, SupplierPart  # ManufacturerPart
 | 
			
		||||
from stock.models import StockItem, StockLocation
 | 
			
		||||
from part.models import Part
 | 
			
		||||
 | 
			
		||||
from common.models import InvenTreeSetting
 | 
			
		||||
from common.views import FileManagementFormView
 | 
			
		||||
 | 
			
		||||
from . import forms as order_forms
 | 
			
		||||
from part.views import PartPricing
 | 
			
		||||
@@ -566,6 +569,192 @@ class SalesOrderShip(AjaxUpdateView):
 | 
			
		||||
        return self.renderJsonResponse(request, form, data, context)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PurchaseOrderUpload(FileManagementFormView):
 | 
			
		||||
    ''' PurchaseOrder: Upload file, match to fields and parts (using multi-Step form) '''
 | 
			
		||||
 | 
			
		||||
    name = 'order'
 | 
			
		||||
    form_steps_template = [
 | 
			
		||||
        'order/order_wizard/po_upload.html',
 | 
			
		||||
        'order/order_wizard/match_fields.html',
 | 
			
		||||
        'order/order_wizard/match_parts.html',
 | 
			
		||||
    ]
 | 
			
		||||
    form_steps_description = [
 | 
			
		||||
        _("Upload File"),
 | 
			
		||||
        _("Match Fields"),
 | 
			
		||||
        _("Match Supplier Parts"),
 | 
			
		||||
    ]
 | 
			
		||||
    # Form field name: PurchaseOrderLineItem field
 | 
			
		||||
    form_field_map = {
 | 
			
		||||
        'item_select': 'part',
 | 
			
		||||
        'quantity': 'quantity',
 | 
			
		||||
        'purchase_price': 'purchase_price',
 | 
			
		||||
        'reference': 'reference',
 | 
			
		||||
        'notes': 'notes',
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    def get_order(self):
 | 
			
		||||
        """ Get order or return 404 """
 | 
			
		||||
 | 
			
		||||
        return get_object_or_404(PurchaseOrder, pk=self.kwargs['pk'])
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, form, **kwargs):
 | 
			
		||||
        context = super().get_context_data(form=form, **kwargs)
 | 
			
		||||
 | 
			
		||||
        order = self.get_order()
 | 
			
		||||
 | 
			
		||||
        context.update({'order': order})
 | 
			
		||||
 | 
			
		||||
        return context
 | 
			
		||||
 | 
			
		||||
    def get_field_selection(self):
 | 
			
		||||
        """ Once data columns have been selected, attempt to pre-select the proper data from the database.
 | 
			
		||||
        This function is called once the field selection has been validated.
 | 
			
		||||
        The pre-fill data are then passed through to the SupplierPart selection form.
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        order = self.get_order()
 | 
			
		||||
 | 
			
		||||
        self.allowed_items = SupplierPart.objects.filter(supplier=order.supplier).prefetch_related('manufacturer_part')
 | 
			
		||||
 | 
			
		||||
        # Fields prefixed with "Part_" can be used to do "smart matching" against Part objects in the database
 | 
			
		||||
        q_idx = self.get_column_index('Quantity')
 | 
			
		||||
        s_idx = self.get_column_index('Supplier_SKU')
 | 
			
		||||
        m_idx = self.get_column_index('Manufacturer_MPN')
 | 
			
		||||
        p_idx = self.get_column_index('Purchase_Price')
 | 
			
		||||
        r_idx = self.get_column_index('Reference')
 | 
			
		||||
        n_idx = self.get_column_index('Notes')
 | 
			
		||||
 | 
			
		||||
        for row in self.rows:
 | 
			
		||||
 | 
			
		||||
            # Initially use a quantity of zero
 | 
			
		||||
            quantity = Decimal(0)
 | 
			
		||||
 | 
			
		||||
            # Initially we do not have a part to reference
 | 
			
		||||
            exact_match_part = None
 | 
			
		||||
 | 
			
		||||
            # Check if there is a column corresponding to "quantity"
 | 
			
		||||
            if q_idx >= 0:
 | 
			
		||||
                q_val = row['data'][q_idx]['cell']
 | 
			
		||||
 | 
			
		||||
                if q_val:
 | 
			
		||||
                    # Delete commas
 | 
			
		||||
                    q_val = q_val.replace(',', '')
 | 
			
		||||
 | 
			
		||||
                    try:
 | 
			
		||||
                        # Attempt to extract a valid quantity from the field
 | 
			
		||||
                        quantity = Decimal(q_val)
 | 
			
		||||
                        # Store the 'quantity' value
 | 
			
		||||
                        row['quantity'] = quantity
 | 
			
		||||
                    except (ValueError, InvalidOperation):
 | 
			
		||||
                        pass
 | 
			
		||||
 | 
			
		||||
            # Check if there is a column corresponding to "Supplier SKU"
 | 
			
		||||
            if s_idx >= 0:
 | 
			
		||||
                sku = row['data'][s_idx]['cell']
 | 
			
		||||
 | 
			
		||||
                try:
 | 
			
		||||
                    # Attempt SupplierPart lookup based on SKU value
 | 
			
		||||
                    exact_match_part = self.allowed_items.get(SKU__contains=sku)
 | 
			
		||||
                except (ValueError, SupplierPart.DoesNotExist, SupplierPart.MultipleObjectsReturned):
 | 
			
		||||
                    exact_match_part = None
 | 
			
		||||
 | 
			
		||||
            # Check if there is a column corresponding to "Manufacturer MPN" and no exact match found yet
 | 
			
		||||
            if m_idx >= 0 and not exact_match_part:
 | 
			
		||||
                mpn = row['data'][m_idx]['cell']
 | 
			
		||||
 | 
			
		||||
                try:
 | 
			
		||||
                    # Attempt SupplierPart lookup based on MPN value
 | 
			
		||||
                    exact_match_part = self.allowed_items.get(manufacturer_part__MPN__contains=mpn)
 | 
			
		||||
                except (ValueError, SupplierPart.DoesNotExist, SupplierPart.MultipleObjectsReturned):
 | 
			
		||||
                    exact_match_part = None
 | 
			
		||||
 | 
			
		||||
            # Supply list of part options for each row, sorted by how closely they match the part name
 | 
			
		||||
            row['item_options'] = self.allowed_items
 | 
			
		||||
 | 
			
		||||
            # Unless found, the 'part_match' is blank
 | 
			
		||||
            row['item_match'] = None
 | 
			
		||||
 | 
			
		||||
            if exact_match_part:
 | 
			
		||||
                # If there is an exact match based on SKU or MPN, use that
 | 
			
		||||
                row['item_match'] = exact_match_part
 | 
			
		||||
 | 
			
		||||
            # Check if there is a column corresponding to "purchase_price"
 | 
			
		||||
            if p_idx >= 0:
 | 
			
		||||
                p_val = row['data'][p_idx]['cell']
 | 
			
		||||
 | 
			
		||||
                if p_val:
 | 
			
		||||
                    # Delete commas
 | 
			
		||||
                    p_val = p_val.replace(',', '')
 | 
			
		||||
 | 
			
		||||
                    try:
 | 
			
		||||
                        # Attempt to extract a valid decimal value from the field
 | 
			
		||||
                        purchase_price = Decimal(p_val)
 | 
			
		||||
                        # Store the 'purchase_price' value
 | 
			
		||||
                        row['purchase_price'] = purchase_price
 | 
			
		||||
                    except (ValueError, InvalidOperation):
 | 
			
		||||
                        pass
 | 
			
		||||
 | 
			
		||||
            # Check if there is a column corresponding to "reference"
 | 
			
		||||
            if r_idx >= 0:
 | 
			
		||||
                reference = row['data'][r_idx]['cell']
 | 
			
		||||
                row['reference'] = reference
 | 
			
		||||
 | 
			
		||||
            # Check if there is a column corresponding to "notes"
 | 
			
		||||
            if n_idx >= 0:
 | 
			
		||||
                notes = row['data'][n_idx]['cell']
 | 
			
		||||
                row['notes'] = notes
 | 
			
		||||
 | 
			
		||||
    def done(self, form_list, **kwargs):
 | 
			
		||||
        """ Once all the data is in, process it to add PurchaseOrderLineItem instances to the order """
 | 
			
		||||
        
 | 
			
		||||
        order = self.get_order()
 | 
			
		||||
 | 
			
		||||
        items = {}
 | 
			
		||||
 | 
			
		||||
        for form_key, form_value in self.get_all_cleaned_data().items():
 | 
			
		||||
            # Split key from row value
 | 
			
		||||
            try:
 | 
			
		||||
                (field, idx) = form_key.split('-')
 | 
			
		||||
            except ValueError:
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
            if idx not in items:
 | 
			
		||||
                # Insert into items
 | 
			
		||||
                items.update({
 | 
			
		||||
                    idx: {
 | 
			
		||||
                        self.form_field_map[field]: form_value,
 | 
			
		||||
                    }
 | 
			
		||||
                })
 | 
			
		||||
            else:
 | 
			
		||||
                # Update items
 | 
			
		||||
                items[idx][self.form_field_map[field]] = form_value
 | 
			
		||||
 | 
			
		||||
        # Create PurchaseOrderLineItem instances
 | 
			
		||||
        for purchase_order_item in items.values():
 | 
			
		||||
            try:
 | 
			
		||||
                supplier_part = SupplierPart.objects.get(pk=int(purchase_order_item['part']))
 | 
			
		||||
            except (ValueError, SupplierPart.DoesNotExist):
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
            quantity = purchase_order_item.get('quantity', 0)
 | 
			
		||||
            if quantity:
 | 
			
		||||
                purchase_order_line_item = PurchaseOrderLineItem(
 | 
			
		||||
                    order=order,
 | 
			
		||||
                    part=supplier_part,
 | 
			
		||||
                    quantity=quantity,
 | 
			
		||||
                    purchase_price=purchase_order_item.get('purchase_price', None),
 | 
			
		||||
                    reference=purchase_order_item.get('reference', ''),
 | 
			
		||||
                    notes=purchase_order_item.get('notes', ''),
 | 
			
		||||
                )
 | 
			
		||||
                try:
 | 
			
		||||
                    purchase_order_line_item.save()
 | 
			
		||||
                except IntegrityError:
 | 
			
		||||
                    # PurchaseOrderLineItem already exists
 | 
			
		||||
                    pass
 | 
			
		||||
            
 | 
			
		||||
        return HttpResponseRedirect(reverse('po-detail', kwargs={'pk': self.kwargs['pk']}))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PurchaseOrderExport(AjaxView):
 | 
			
		||||
    """ File download for a purchase order
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -58,14 +58,14 @@
 | 
			
		||||
        <tr>
 | 
			
		||||
            <td></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>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
        {% if part.keywords %}
 | 
			
		||||
        <tr>
 | 
			
		||||
            <td><span class='fas fa-key'></span></td>
 | 
			
		||||
            <td><strong>{% trans "Keywords" %}</strong></td>
 | 
			
		||||
            <td>{{ part.keywords }}</td>
 | 
			
		||||
            <td>{{ part.keywords }}{% include "clip.html"%}</td>
 | 
			
		||||
        </tr>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
        <tr>
 | 
			
		||||
@@ -73,7 +73,7 @@
 | 
			
		||||
            <td><strong>{% trans "Category" %}</strong></td>
 | 
			
		||||
            <td>
 | 
			
		||||
                {% 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 %}
 | 
			
		||||
            </td>
 | 
			
		||||
        </tr>
 | 
			
		||||
@@ -81,14 +81,14 @@
 | 
			
		||||
        <tr>
 | 
			
		||||
            <td><span class='fas fa-link'></span></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>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
        {% if part.default_location %}
 | 
			
		||||
        <tr>
 | 
			
		||||
            <td><span class='fas fa-map-marker-alt'></span></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>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
        {% if part.default_supplier %}
 | 
			
		||||
@@ -96,15 +96,15 @@
 | 
			
		||||
            <td></td>
 | 
			
		||||
            <td><strong>{% trans "Default Supplier" %}</strong></td>
 | 
			
		||||
            <td><a href="{% url 'supplier-part-detail' part.default_supplier.id %}">
 | 
			
		||||
                {{ part.default_supplier.supplier.name }} | {{ part.default_supplier.SKU }}{% include "clip.html"%}
 | 
			
		||||
            </a></td>
 | 
			
		||||
                {{ part.default_supplier.supplier.name }} | {{ part.default_supplier.SKU }}
 | 
			
		||||
            </a>{% include "clip.html"%}</td>
 | 
			
		||||
        </tr>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
        {% if part.units %}
 | 
			
		||||
        <tr>
 | 
			
		||||
            <td></td>
 | 
			
		||||
            <td><strong>{% trans "Units" %}</strong></td>
 | 
			
		||||
            <td>{{ part.units }}</td>
 | 
			
		||||
            <td>{{ part.units }}{% include "clip.html"%}</td>
 | 
			
		||||
        </tr>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
        {% if part.minimum_stock > 0 %}
 | 
			
		||||
 
 | 
			
		||||
@@ -208,16 +208,24 @@ class ReportPrintMixin:
 | 
			
		||||
        # In debug mode, generate single HTML output, rather than PDF
 | 
			
		||||
        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
 | 
			
		||||
        for item in items_to_print:
 | 
			
		||||
            report = self.get_object()
 | 
			
		||||
            report.object_to_print = item
 | 
			
		||||
 | 
			
		||||
            report_name = report.generate_filename(request)
 | 
			
		||||
 | 
			
		||||
            if debug_mode:
 | 
			
		||||
                outputs.append(report.render_as_string(request))
 | 
			
		||||
            else:
 | 
			
		||||
                outputs.append(report.render(request))
 | 
			
		||||
 | 
			
		||||
        if not report_name.endswith('.pdf'):
 | 
			
		||||
            report_name += '.pdf'
 | 
			
		||||
 | 
			
		||||
        if debug_mode:
 | 
			
		||||
            """
 | 
			
		||||
            Contatenate all rendered templates into a single HTML string,
 | 
			
		||||
@@ -248,7 +256,7 @@ class ReportPrintMixin:
 | 
			
		||||
 | 
			
		||||
            return InvenTree.helpers.DownloadFile(
 | 
			
		||||
                pdf,
 | 
			
		||||
                'inventree_report.pdf',
 | 
			
		||||
                report_name,
 | 
			
		||||
                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.template.loader import render_to_string
 | 
			
		||||
from django.template import Template, Context
 | 
			
		||||
 | 
			
		||||
from django.core.files.storage import FileSystemStorage
 | 
			
		||||
from django.core.validators import FileExtensionValidator
 | 
			
		||||
@@ -224,6 +225,7 @@ class ReportTemplateBase(ReportBase):
 | 
			
		||||
        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['base_url'] = common.models.InvenTreeSetting.get_setting('INVENTREE_BASE_URL')
 | 
			
		||||
@@ -238,9 +240,22 @@ class ReportTemplateBase(ReportBase):
 | 
			
		||||
 | 
			
		||||
        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):
 | 
			
		||||
        """
 | 
			
		||||
        Render the report to a HTML stiring.
 | 
			
		||||
        Render the report to a HTML string.
 | 
			
		||||
 | 
			
		||||
        Useful for debug mode (viewing generated code)
 | 
			
		||||
        """
 | 
			
		||||
@@ -263,12 +278,20 @@ class ReportTemplateBase(ReportBase):
 | 
			
		||||
            self.template_name,
 | 
			
		||||
            base_url=request.build_absolute_uri("/"),
 | 
			
		||||
            presentational_hints=True,
 | 
			
		||||
            filename=self.generate_filename(request),
 | 
			
		||||
            **kwargs)
 | 
			
		||||
 | 
			
		||||
        return wp.render_to_response(
 | 
			
		||||
            self.context(request),
 | 
			
		||||
            **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(
 | 
			
		||||
        default=True,
 | 
			
		||||
        verbose_name=_('Enabled'),
 | 
			
		||||
@@ -326,6 +349,7 @@ class TestReport(ReportTemplateBase):
 | 
			
		||||
 | 
			
		||||
        return {
 | 
			
		||||
            'stock_item': stock_item,
 | 
			
		||||
            'serial': stock_item.serial,
 | 
			
		||||
            'part': stock_item.part,
 | 
			
		||||
            'results': stock_item.testResultMap(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(),
 | 
			
		||||
            'reference': my_build.reference,
 | 
			
		||||
            'quantity': my_build.quantity,
 | 
			
		||||
            'title': str(my_build),
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -21,7 +21,7 @@
 | 
			
		||||
                                <td><span class='fas fa-hashtag'></span></td>
 | 
			
		||||
                                <td>{% trans "InvenTree Version" %}</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 %}
 | 
			
		||||
                                    <span class='label label-green float-right'>{% trans "Up to Date" %}</span>
 | 
			
		||||
                                    {% else %}
 | 
			
		||||
@@ -32,20 +32,20 @@
 | 
			
		||||
                            <tr>
 | 
			
		||||
                                <td><span class='fas fa-hashtag'></span></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>
 | 
			
		||||
                            {% inventree_commit_hash as hash %}
 | 
			
		||||
                            {% if hash %}
 | 
			
		||||
                            <tr>
 | 
			
		||||
                                <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>
 | 
			
		||||
                            {% endif %}
 | 
			
		||||
                            {% inventree_commit_date as commit_date %}
 | 
			
		||||
                            {% if commit_date %}
 | 
			
		||||
                            <tr>
 | 
			
		||||
                                <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>
 | 
			
		||||
                            {% endif %}
 | 
			
		||||
                            <tr>
 | 
			
		||||
@@ -73,6 +73,14 @@
 | 
			
		||||
                                <td>{% trans "Submit Bug Report" %}</td>
 | 
			
		||||
                                <td><a href='{% inventree_github_url %}/issues'>{% inventree_github_url %}issues</a></td>
 | 
			
		||||
                            </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>
 | 
			
		||||
 | 
			
		||||
                    </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
 | 
			
		||||
ARG repository="https://github.com/inventree/InvenTree.git"
 | 
			
		||||
@@ -73,6 +73,7 @@ RUN pip install --no-cache-dir -U invoke
 | 
			
		||||
RUN pip install --no-cache-dir -U psycopg2 mysqlclient pgcli mariadb
 | 
			
		||||
RUN pip install --no-cache-dir -U gunicorn
 | 
			
		||||
 | 
			
		||||
FROM base as production
 | 
			
		||||
# Clone source code
 | 
			
		||||
RUN echo "Downloading InvenTree from ${INVENTREE_REPO}"
 | 
			
		||||
RUN git clone --branch ${INVENTREE_BRANCH} --depth 1 ${INVENTREE_REPO} ${INVENTREE_SRC_DIR}
 | 
			
		||||
@@ -85,11 +86,9 @@ COPY gunicorn.conf.py ${INVENTREE_HOME}/gunicorn.conf.py
 | 
			
		||||
 | 
			
		||||
# Copy startup scripts
 | 
			
		||||
COPY start_prod_server.sh ${INVENTREE_SRC_DIR}/start_prod_server.sh
 | 
			
		||||
COPY start_dev_server.sh ${INVENTREE_SRC_DIR}/start_dev_server.sh
 | 
			
		||||
COPY start_worker.sh ${INVENTREE_SRC_DIR}/start_worker.sh
 | 
			
		||||
 | 
			
		||||
RUN chmod 755 ${INVENTREE_SRC_DIR}/start_prod_server.sh
 | 
			
		||||
RUN chmod 755 ${INVENTREE_SRC_DIR}/start_dev_server.sh
 | 
			
		||||
RUN chmod 755 ${INVENTREE_SRC_DIR}/start_worker.sh
 | 
			
		||||
 | 
			
		||||
# exec commands should be executed from the "src" directory
 | 
			
		||||
@@ -97,3 +96,17 @@ WORKDIR ${INVENTREE_SRC_DIR}
 | 
			
		||||
 | 
			
		||||
# Let us begin
 | 
			
		||||
CMD ["bash", "./start_prod_server.sh"]
 | 
			
		||||
 | 
			
		||||
FROM base as dev
 | 
			
		||||
 | 
			
		||||
# The development image requires the source code to be mounted to /home/inventree/src/
 | 
			
		||||
# So from here, we don't actually "do" anything
 | 
			
		||||
 | 
			
		||||
WORKDIR ${INVENTREE_SRC_DIR}
 | 
			
		||||
 | 
			
		||||
COPY start_dev_server.sh ${INVENTREE_HOME}/start_dev_server.sh
 | 
			
		||||
COPY start_dev_worker.sh ${INVENTREE_HOME}/start_dev_worker.sh
 | 
			
		||||
RUN chmod 755 ${INVENTREE_HOME}/start_dev_server.sh
 | 
			
		||||
RUN chmod 755 ${INVENTREE_HOME}/start_dev_worker.sh
 | 
			
		||||
 | 
			
		||||
CMD ["bash", "/home/inventree/start_dev_server.sh"]
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										7
									
								
								docker/dev-config.env
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								docker/dev-config.env
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
			
		||||
INVENTREE_DB_ENGINE=sqlite3
 | 
			
		||||
INVENTREE_DB_NAME=/home/inventree/src/inventree_docker_dev.sqlite3
 | 
			
		||||
INVENTREE_MEDIA_ROOT=/home/inventree/src/inventree_media
 | 
			
		||||
INVENTREE_STATIC_ROOT=/home/inventree/src/inventree_static
 | 
			
		||||
INVENTREE_CONFIG_FILE=/home/inventree/src/config.yaml
 | 
			
		||||
INVENTREE_SECRET_KEY_FILE=/home/inventree/src/secret_key.txt
 | 
			
		||||
INVENTREE_DEBUG=true
 | 
			
		||||
							
								
								
									
										59
									
								
								docker/docker-compose.dev.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								docker/docker-compose.dev.yml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,59 @@
 | 
			
		||||
version: "3.8"
 | 
			
		||||
 | 
			
		||||
# Docker compose recipe for InvenTree development server
 | 
			
		||||
# - Runs sqlite3 as the database backend
 | 
			
		||||
# - Uses built-in django webserver
 | 
			
		||||
 | 
			
		||||
# IMPORANT NOTE:
 | 
			
		||||
# The InvenTree docker image does not clone source code from git.
 | 
			
		||||
# Instead, you must specify *where* the source code is located,
 | 
			
		||||
# (on your local machine).
 | 
			
		||||
# The django server will auto-detect any code changes and reload the server.
 | 
			
		||||
 | 
			
		||||
services:
 | 
			
		||||
    # InvenTree web server services
 | 
			
		||||
    # Uses gunicorn as the web server
 | 
			
		||||
    inventree-server:
 | 
			
		||||
        container_name: inventree-server
 | 
			
		||||
        build:
 | 
			
		||||
            context: .
 | 
			
		||||
            target: dev
 | 
			
		||||
        ports:
 | 
			
		||||
            - 8000:8000
 | 
			
		||||
        volumes:
 | 
			
		||||
            # Ensure you specify the location of the 'src' directory at the end of this file
 | 
			
		||||
            - src:/home/inventree/src
 | 
			
		||||
        env_file:
 | 
			
		||||
            # Environment variables required for the dev server are configured in dev-config.env
 | 
			
		||||
            - dev-config.env
 | 
			
		||||
 | 
			
		||||
        restart: unless-stopped
 | 
			
		||||
 | 
			
		||||
    # Background worker process handles long-running or periodic tasks
 | 
			
		||||
    inventree-worker:
 | 
			
		||||
        container_name: inventree-worker
 | 
			
		||||
        build:
 | 
			
		||||
            context: .
 | 
			
		||||
            target: dev
 | 
			
		||||
        entrypoint: /home/inventree/start_dev_worker.sh
 | 
			
		||||
        depends_on:
 | 
			
		||||
            - inventree-server
 | 
			
		||||
        volumes:
 | 
			
		||||
            # Ensure you specify the location of the 'src' directory at the end of this file
 | 
			
		||||
            - src:/home/inventree/src
 | 
			
		||||
        env_file:
 | 
			
		||||
            # Environment variables required for the dev server are configured in dev-config.env
 | 
			
		||||
            - dev-config.env
 | 
			
		||||
        restart: unless-stopped
 | 
			
		||||
 | 
			
		||||
volumes:
 | 
			
		||||
    # NOTE: Change /path/to/src to a directory on your local machine, where the InvenTree source code is located
 | 
			
		||||
    # Persistent data, stored external to the container(s)
 | 
			
		||||
    src:
 | 
			
		||||
        driver: local
 | 
			
		||||
        driver_opts:
 | 
			
		||||
            type: none
 | 
			
		||||
            o: bind
 | 
			
		||||
            # This directory specified where InvenTree source code is stored "outside" the docker containers
 | 
			
		||||
            # Note: This directory must conatin the file *manage.py*
 | 
			
		||||
            device: /path/to/inventree/src
 | 
			
		||||
@@ -19,6 +19,14 @@ else
 | 
			
		||||
    cp $INVENTREE_SRC_DIR/InvenTree/config_template.yaml $INVENTREE_CONFIG_FILE
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Setup a virtual environment
 | 
			
		||||
python3 -m venv inventree-docker-dev
 | 
			
		||||
 | 
			
		||||
source inventree-docker-dev/bin/activate
 | 
			
		||||
 | 
			
		||||
echo "Installing required packages..."
 | 
			
		||||
pip install --no-cache-dir -U -r ${INVENTREE_SRC_DIR}/requirements.txt
 | 
			
		||||
 | 
			
		||||
echo "Starting InvenTree server..."
 | 
			
		||||
 | 
			
		||||
# Wait for the database to be ready
 | 
			
		||||
@@ -27,16 +35,14 @@ python manage.py wait_for_db
 | 
			
		||||
 | 
			
		||||
sleep 10
 | 
			
		||||
 | 
			
		||||
echo "Running InvenTree database migrations and collecting static files..."
 | 
			
		||||
echo "Running InvenTree database migrations..."
 | 
			
		||||
 | 
			
		||||
# We assume at this stage that the database is up and running
 | 
			
		||||
# Ensure that the database schema are up to date
 | 
			
		||||
python manage.py check || exit 1
 | 
			
		||||
python manage.py migrate --noinput || exit 1
 | 
			
		||||
python manage.py migrate --run-syncdb || exit 1
 | 
			
		||||
python manage.py prerender || exit 1
 | 
			
		||||
python manage.py collectstatic --noinput || exit 1
 | 
			
		||||
python manage.py clearsessions || exit 1
 | 
			
		||||
 | 
			
		||||
# Launch a development server
 | 
			
		||||
python manage.py runserver -a 0.0.0.0:$INVENTREE_WEB_PORT
 | 
			
		||||
python manage.py runserver 0.0.0.0:$INVENTREE_WEB_PORT
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										19
									
								
								docker/start_dev_worker.sh
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								docker/start_dev_worker.sh
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,19 @@
 | 
			
		||||
#!/bin/sh
 | 
			
		||||
 | 
			
		||||
echo "Starting InvenTree worker..."
 | 
			
		||||
 | 
			
		||||
cd $INVENTREE_SRC_DIR
 | 
			
		||||
 | 
			
		||||
# Activate virtual environment
 | 
			
		||||
source inventree-docker-dev/bin/activate
 | 
			
		||||
 | 
			
		||||
sleep 5
 | 
			
		||||
 | 
			
		||||
# Wait for the database to be ready
 | 
			
		||||
cd $INVENTREE_MNG_DIR
 | 
			
		||||
python manage.py wait_for_db
 | 
			
		||||
 | 
			
		||||
sleep 10
 | 
			
		||||
 | 
			
		||||
# Now we can launch the background worker process
 | 
			
		||||
python manage.py qcluster
 | 
			
		||||
@@ -11,9 +11,10 @@ django-markdownx==3.0.1         # Markdown form fields
 | 
			
		||||
django-markdownify==0.8.0       # Markdown rendering
 | 
			
		||||
coreapi==2.3.0                  # API documentation
 | 
			
		||||
pygments==2.7.4                 # Syntax highlighting
 | 
			
		||||
tablib==0.13.0                  # Import / export data files
 | 
			
		||||
# tablib==0.13.0                # Import / export data files (installed as dependency of django-import-export package)
 | 
			
		||||
django-crispy-forms==1.11.2     # Form helpers
 | 
			
		||||
django-import-export==2.0.0     # Data import / export for admin interface
 | 
			
		||||
tablib[xls,xlsx,yaml]           # Support for XLS and XLSX formats
 | 
			
		||||
django-cleanup==5.1.0           # Manage deletion of old / unused uploaded files
 | 
			
		||||
flake8==3.8.3                   # PEP checking
 | 
			
		||||
pep8-naming==0.11.1             # PEP naming convention extension
 | 
			
		||||
@@ -32,5 +33,6 @@ python-barcode[images]==0.13.1  # Barcode generator
 | 
			
		||||
qrcode[pil]==6.1                # QR code generator
 | 
			
		||||
django-q==1.3.4                 # Background task scheduling
 | 
			
		||||
gunicorn>=20.0.4                # Gunicorn web server
 | 
			
		||||
django-formtools==2.3           # Form wizard tools
 | 
			
		||||
 | 
			
		||||
inventree                       # Install the latest version of the InvenTree API python library
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user