diff --git a/.github/workflows/docker_build.yaml b/.github/workflows/docker_build.yaml index 26fc69a0f5..ec8bdf7306 100644 --- a/.github/workflows/docker_build.yaml +++ b/.github/workflows/docker_build.yaml @@ -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 diff --git a/.github/workflows/docker_publish.yaml b/.github/workflows/docker_publish.yaml index c25696d6dd..4a8cef0952 100644 --- a/.github/workflows/docker_publish.yaml +++ b/.github/workflows/docker_publish.yaml @@ -28,4 +28,5 @@ jobs: repository: inventree/inventree tag_with_ref: true dockerfile: ./Dockerfile + target: production platforms: linux/amd64,linux/arm64,linux/arm/v7 diff --git a/.gitignore b/.gitignore index 54ad8f07b6..7c360a8231 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ var/ *.log local_settings.py *.sqlite3 +*.sqlite3-journal *.backup *.old diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index cc61748372..7ff90fc7c3 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -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'] diff --git a/InvenTree/InvenTree/static/script/inventree/inventree.js b/InvenTree/InvenTree/static/script/inventree/inventree.js index b3209ca267..4b43b342af 100644 --- a/InvenTree/InvenTree/static/script/inventree/inventree.js +++ b/InvenTree/InvenTree/static/script/inventree/inventree.js @@ -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 } diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index 9bc1dd77cc..1b4c577b07 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -19,12 +19,12 @@ {% trans "Description" %} - {{ build.title }} + {{ build.title }}{% include "clip.html"%} {% trans "Part" %} - {{ build.part.full_name }} + {{ build.part.full_name }}{% include "clip.html"%} @@ -35,7 +35,7 @@ {% trans "Stock Source" %} {% if build.take_from %} - {{ build.take_from }} + {{ build.take_from }}{% include "clip.html"%} {% else %} {% trans "Stock can be taken from any available location." %} {% endif %} @@ -48,7 +48,7 @@ {% if build.destination %} {{ build.destination }} - + {% include "clip.html"%} {% else %} {% trans "Destination location not specified" %} {% endif %} @@ -68,28 +68,28 @@ {% trans "Batch" %} - {{ build.batch }} + {{ build.batch }}{% include "clip.html"%} {% endif %} {% if build.parent %} {% trans "Parent Build" %} - {{ build.parent }} + {{ build.parent }}{% include "clip.html"%} {% endif %} {% if build.sales_order %} {% trans "Sales Order" %} - {{ build.sales_order }} + {{ build.sales_order }}{% include "clip.html"%} {% endif %} {% if build.link %} {% trans "External Link" %} - {{ build.link }} + {{ build.link }}{% include "clip.html"%} {% endif %} {% if build.issued_by %} diff --git a/InvenTree/common/files.py b/InvenTree/common/files.py new file mode 100644 index 0000000000..377120f44d --- /dev/null +++ b/InvenTree/common/files.py @@ -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] diff --git a/InvenTree/common/forms.py b/InvenTree/common/forms.py index 84e44f3a31..8a0017e38b 100644 --- a/InvenTree/common/forms.py +++ b/InvenTree/common/forms.py @@ -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, + ) diff --git a/InvenTree/common/views.py b/InvenTree/common/views.py index 8cc344c9ab..fa605c2b80 100644 --- a/InvenTree/common/views.py +++ b/InvenTree/common/views.py @@ -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_ - Column name at idx as provided in the uploaded file + col_guess_ - Column guess at idx as selected + row__col - 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_ 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__col_ + 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) diff --git a/InvenTree/company/templates/company/company_base.html b/InvenTree/company/templates/company/company_base.html index 1825132b0e..1eefade272 100644 --- a/InvenTree/company/templates/company/company_base.html +++ b/InvenTree/company/templates/company/company_base.html @@ -68,35 +68,35 @@ {% trans "Website" %} - {{ company.website }} + {{ company.website }}{% include "clip.html"%} {% endif %} {% if company.address %} {% trans "Address" %} - {{ company.address }} + {{ company.address }}{% include "clip.html"%} {% endif %} {% if company.phone %} {% trans "Phone" %} - {{ company.phone }} + {% include "tel.html" with tel=company.phone %} {% endif %} {% if company.email %} {% trans "Email" %} - {{ company.email }} + {% include "mail.html" with mail=company.email %} {% endif %} {% if company.contact %} {% trans "Contact" %} - {{ company.contact }} + {{ company.contact }}{% include "clip.html"%} {% endif %} diff --git a/InvenTree/company/templates/company/detail.html b/InvenTree/company/templates/company/detail.html index 9fdd6d0c05..9c3cbfb84a 100644 --- a/InvenTree/company/templates/company/detail.html +++ b/InvenTree/company/templates/company/detail.html @@ -19,20 +19,20 @@ {% trans "Company Name" %} - {{ company.name }} + {{ company.name }}{% include "clip.html"%} {% if company.description %} {% trans "Description" %} - {{ company.description }} + {{ company.description }}{% include "clip.html"%} {% endif %} {% trans "Website" %} - {% if company.website %}{{ company.website }} + {% if company.website %}{{ company.website }}{% include "clip.html"%} {% else %}{% trans "No website specified" %} {% endif %} diff --git a/InvenTree/company/templates/company/manufacturer_part_base.html b/InvenTree/company/templates/company/manufacturer_part_base.html index 441f1f845b..c3a64d9d76 100644 --- a/InvenTree/company/templates/company/manufacturer_part_base.html +++ b/InvenTree/company/templates/company/manufacturer_part_base.html @@ -62,7 +62,7 @@ src="{% static 'img/blank_image.png' %}" {% trans "Internal Part" %} {% if part.part %} - {{ part.part.full_name }} + {{ part.part.full_name }}{% include "clip.html"%} {% endif %} @@ -70,24 +70,24 @@ src="{% static 'img/blank_image.png' %}" {% trans "Description" %} - {{ part.description }} + {{ part.description }}{% include "clip.html"%} {% endif %} {% if part.link %} {% trans "External Link" %} - {{ part.link }} + {{ part.link }}{% include "clip.html"%} {% endif %} {% trans "Manufacturer" %} - {{ part.manufacturer.name }} + {{ part.manufacturer.name }}{% include "clip.html"%} {% trans "MPN" %} - {{ part.MPN }} + {{ part.MPN }}{% include "clip.html"%} {% endblock %} diff --git a/InvenTree/company/templates/company/supplier_part_base.html b/InvenTree/company/templates/company/supplier_part_base.html index 3716128bd8..bf6d914f19 100644 --- a/InvenTree/company/templates/company/supplier_part_base.html +++ b/InvenTree/company/templates/company/supplier_part_base.html @@ -61,7 +61,7 @@ src="{% static 'img/blank_image.png' %}" {% trans "Internal Part" %} {% if part.part %} - {{ part.part.full_name }} + {{ part.part.full_name }}{% include "clip.html"%} {% endif %} @@ -69,51 +69,52 @@ src="{% static 'img/blank_image.png' %}" {% trans "Description" %} - {{ part.description }} + {{ part.description }}{% include "clip.html"%} {% endif %} {% if part.link %} {% trans "External Link" %} - {{ part.link }} + {{ part.link }}{% include "clip.html"%} {% endif %} {% trans "Supplier" %} - {{ part.supplier.name }} + {{ part.supplier.name }}{% include "clip.html"%} {% trans "SKU" %} - {{ part.SKU }} + {{ part.SKU }}{% include "clip.html"%} {% if part.manufacturer_part.manufacturer %} {% trans "Manufacturer" %} - {{ part.manufacturer_part.manufacturer.name }} + + {{ part.manufacturer_part.manufacturer.name }}{% include "clip.html"%} {% endif %} {% if part.manufacturer_part.MPN %} {% trans "MPN" %} - {{ part.manufacturer_part.MPN }} + {{ part.manufacturer_part.MPN }}{% include "clip.html"%} {% endif %} {% if part.packaging %} {% trans "Packaging" %} - {{ part.packaging }} + {{ part.packaging }}{% include "clip.html"%} {% endif %} {% if part.note %} {% trans "Note" %} - {{ part.note }} + {{ part.note }}{% include "clip.html"%} {% endif %} diff --git a/InvenTree/company/templates/company/supplier_part_detail.html b/InvenTree/company/templates/company/supplier_part_detail.html index 285f81c326..fb73ca06f4 100644 --- a/InvenTree/company/templates/company/supplier_part_detail.html +++ b/InvenTree/company/templates/company/supplier_part_detail.html @@ -28,14 +28,14 @@ {% trans "External Link" %}{{ part.link }} {% endif %} {% if part.description %} - {% trans "Description" %}{{ part.description }} + {% trans "Description" %}{{ part.description }}{% include "clip.html"%} {% endif %} {% if part.manufacturer %} - {% trans "Manufacturer" %}{{ part.manufacturer }} - {% trans "MPN" %}{{ part.MPN }} + {% trans "Manufacturer" %}{{ part.manufacturer }}{% include "clip.html"%} + {% trans "MPN" %}{{ part.MPN }}{% include "clip.html"%} {% endif %} {% if part.note %} - {% trans "Note" %}{{ part.note }} + {% trans "Note" %}{{ part.note }}{% include "clip.html"%} {% endif %} diff --git a/InvenTree/label/api.py b/InvenTree/label/api.py index fc082e4a50..8522857e30 100644 --- a/InvenTree/label/api.py +++ b/InvenTree/label/api.py @@ -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' ) diff --git a/InvenTree/label/migrations/0007_auto_20210513_1327.py b/InvenTree/label/migrations/0007_auto_20210513_1327.py new file mode 100644 index 0000000000..d49c83c92b --- /dev/null +++ b/InvenTree/label/migrations/0007_auto_20210513_1327.py @@ -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'), + ), + ] diff --git a/InvenTree/label/models.py b/InvenTree/label/models.py index 71ccc73ac9..a5d8314193 100644 --- a/InvenTree/label/models.py +++ b/InvenTree/label/models.py @@ -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 ) diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html index b29b62a4ad..642f866506 100644 --- a/InvenTree/order/templates/order/order_base.html +++ b/InvenTree/order/templates/order/order_base.html @@ -33,7 +33,7 @@ src="{% static 'img/blank_image.png' %}" {% endif %}
-

{{ order.description }}

+

{{ order.description }}{% include "clip.html"%}

+ {% endif %} + +{% endblock form_buttons_top %} + +{% block form_content %} + + + {% trans "File Fields" %} + + {% for col in form %} + +
+ + {{ col.name }} + +
+ + {% endfor %} + + + + + {% trans "Match Fields" %} + + {% for col in form %} + + {{ col }} + {% for duplicate in duplicates %} + {% if duplicate == col.name %} + + {% endif %} + {% endfor %} + + {% endfor %} + + {% for row in rows %} + {% with forloop.counter as row_index %} + + + + + {{ row_index }} + {% for item in row.data %} + + + {{ item }} + + {% endfor %} + + {% endwith %} + {% endfor %} + +{% endblock form_content %} + +{% block form_buttons_bottom %} +{% endblock form_buttons_bottom %} + +{% block js_ready %} +{{ block.super }} + +$('.fieldselect').select2({ + width: '100%', + matcher: partialMatcher, +}); + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/order/templates/order/order_wizard/match_parts.html b/InvenTree/order/templates/order/order_wizard/match_parts.html new file mode 100644 index 0000000000..f97edff913 --- /dev/null +++ b/InvenTree/order/templates/order/order_wizard/match_parts.html @@ -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 %} + +{% endif %} +{% endblock form_alert %} + +{% block form_buttons_top %} + {% if wizard.steps.prev %} + + {% endif %} + +{% endblock form_buttons_top %} + +{% block form_content %} + + + + {% trans "Row" %} + {% trans "Select Supplier Part" %} + {% trans "Quantity" %} + {% for col in columns %} + {% if col.guess != 'Quantity' %} + + + + {% if col.guess %} + {{ col.guess }} + {% else %} + {{ col.name }} + {% endif %} + + {% endif %} + {% endfor %} + + + + {% comment %} Dummy row for javascript del_row method {% endcomment %} + {% for row in rows %} + + + + + + {% add row.index 1 %} + + + {% for field in form.visible_fields %} + {% if field.name == row.item_select %} + {{ field }} + {% endif %} + {% endfor %} + {% if row.errors.part %} +

{{ row.errors.part }}

+ {% endif %} + + + {% for field in form.visible_fields %} + {% if field.name == row.quantity %} + {{ field }} + {% endif %} + {% endfor %} + {% if row.errors.quantity %} +

{{ row.errors.quantity }}

+ {% endif %} + + {% for item in row.data %} + {% if item.column.guess != 'Quantity' %} + + {% 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 %} + + + {% endif %} + {% endfor %} + + {% endfor %} + +{% 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 %} \ No newline at end of file diff --git a/InvenTree/order/templates/order/order_wizard/po_upload.html b/InvenTree/order/templates/order/order_wizard/po_upload.html new file mode 100644 index 0000000000..a281725173 --- /dev/null +++ b/InvenTree/order/templates/order/order_wizard/po_upload.html @@ -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 %} + +

{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %} +{% if description %}- {{ description }}{% endif %}

+ +{% block form_alert %} +{% endblock form_alert %} + +
+{% csrf_token %} +{% load crispy_forms_tags %} + +{% block form_buttons_top %} +{% endblock form_buttons_top %} + + +{{ wizard.management_form }} +{% block form_content %} +{% crispy wizard.form %} +{% endblock form_content %} +
+ +{% block form_buttons_bottom %} +{% if wizard.steps.prev %} + +{% endif %} + +
+{% endblock form_buttons_bottom %} + +{% else %} + +{% endif %} +{% endblock details %} + +{% block js_ready %} +{{ block.super }} + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/order/templates/order/po_navbar.html b/InvenTree/order/templates/order/po_navbar.html index eac24d47b3..f8e818c2e3 100644 --- a/InvenTree/order/templates/order/po_navbar.html +++ b/InvenTree/order/templates/order/po_navbar.html @@ -1,6 +1,7 @@ {% load i18n %} {% load static %} {% load inventree_extras %} +{% load status_codes %}
  • @@ -14,6 +15,14 @@ {% trans "Details" %}
  • + {% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change %} +
  • + + + {% trans "Upload File" %} + +
  • + {% endif %}
  • diff --git a/InvenTree/order/templates/order/sales_order_base.html b/InvenTree/order/templates/order/sales_order_base.html index f0a71b8fec..45bcc76244 100644 --- a/InvenTree/order/templates/order/sales_order_base.html +++ b/InvenTree/order/templates/order/sales_order_base.html @@ -42,7 +42,7 @@ src="{% static 'img/blank_image.png' %}" {% endif %}
    -

    {{ order.description }}

    +

    {{ order.description }}{% include "clip.html"%}

    + + +
    diff --git a/InvenTree/templates/mail.html b/InvenTree/templates/mail.html new file mode 100644 index 0000000000..98990fb31b --- /dev/null +++ b/InvenTree/templates/mail.html @@ -0,0 +1 @@ +{{ mail }}{% include "clip.html"%} \ No newline at end of file diff --git a/InvenTree/templates/tel.html b/InvenTree/templates/tel.html new file mode 100644 index 0000000000..14f978ad87 --- /dev/null +++ b/InvenTree/templates/tel.html @@ -0,0 +1 @@ +{{ tel }}{% include "clip.html"%} \ No newline at end of file diff --git a/InvenTree/templates/version.html b/InvenTree/templates/version.html new file mode 100644 index 0000000000..c8ec6862b6 --- /dev/null +++ b/InvenTree/templates/version.html @@ -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 %} \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile index c95c2867df..3e0a7e1230 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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"] diff --git a/docker/dev-config.env b/docker/dev-config.env new file mode 100644 index 0000000000..200c3db479 --- /dev/null +++ b/docker/dev-config.env @@ -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 \ No newline at end of file diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml new file mode 100644 index 0000000000..ddf50135c9 --- /dev/null +++ b/docker/docker-compose.dev.yml @@ -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 diff --git a/docker/start_dev_server.sh b/docker/start_dev_server.sh index c22805b90b..0c1564076a 100644 --- a/docker/start_dev_server.sh +++ b/docker/start_dev_server.sh @@ -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 \ No newline at end of file +python manage.py runserver 0.0.0.0:$INVENTREE_WEB_PORT diff --git a/docker/start_dev_worker.sh b/docker/start_dev_worker.sh new file mode 100644 index 0000000000..099f447a9c --- /dev/null +++ b/docker/start_dev_worker.sh @@ -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 diff --git a/requirements.txt b/requirements.txt index 3291574084..35963ce718 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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