diff --git a/.gitignore b/.gitignore index 5610fc4304..f3fa0ac8c1 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ local_settings.py # Files used for testing dummy_image.* +_tmp.csv # Sphinx files docs/_build diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index feb46ee667..ee86b975dc 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -32,27 +32,37 @@ class InvenTreeConfig(AppConfig): logger.info("Starting background tasks...") + # Remove successful task results from the database InvenTree.tasks.schedule_task( 'InvenTree.tasks.delete_successful_tasks', schedule_type=Schedule.DAILY, ) + # Check for InvenTree updates InvenTree.tasks.schedule_task( 'InvenTree.tasks.check_for_updates', schedule_type=Schedule.DAILY ) + # Heartbeat to let the server know the background worker is running InvenTree.tasks.schedule_task( 'InvenTree.tasks.heartbeat', schedule_type=Schedule.MINUTES, minutes=15 ) + # Keep exchange rates up to date InvenTree.tasks.schedule_task( 'InvenTree.tasks.update_exchange_rates', schedule_type=Schedule.DAILY, ) + # Remove expired sessions + InvenTree.tasks.schedule_task( + 'InvenTree.tasks.delete_expired_sessions', + schedule_type=Schedule.DAILY, + ) + def update_exchange_rates(self): """ Update exchange rates each time the server is started, *if*: diff --git a/InvenTree/InvenTree/models.py b/InvenTree/InvenTree/models.py index 3213838e78..2ca179bb40 100644 --- a/InvenTree/InvenTree/models.py +++ b/InvenTree/InvenTree/models.py @@ -5,8 +5,10 @@ Generic models which provide extra functionality over base Django model types. from __future__ import unicode_literals import os +import logging from django.db import models +from django.conf import settings from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext_lazy as _ @@ -21,6 +23,9 @@ from mptt.exceptions import InvalidMove from .validators import validate_tree_name +logger = logging.getLogger('inventree') + + def rename_attachment(instance, filename): """ Function for renaming an attachment file. @@ -77,6 +82,72 @@ class InvenTreeAttachment(models.Model): def basename(self): return os.path.basename(self.attachment.name) + @basename.setter + def basename(self, fn): + """ + Function to rename the attachment file. + + - Filename cannot be empty + - Filename cannot contain illegal characters + - Filename must specify an extension + - Filename cannot match an existing file + """ + + fn = fn.strip() + + if len(fn) == 0: + raise ValidationError(_('Filename must not be empty')) + + attachment_dir = os.path.join( + settings.MEDIA_ROOT, + self.getSubdir() + ) + + old_file = os.path.join( + settings.MEDIA_ROOT, + self.attachment.name + ) + + new_file = os.path.join( + settings.MEDIA_ROOT, + self.getSubdir(), + fn + ) + + new_file = os.path.abspath(new_file) + + # Check that there are no directory tricks going on... + if not os.path.dirname(new_file) == attachment_dir: + logger.error(f"Attempted to rename attachment outside valid directory: '{new_file}'") + raise ValidationError(_("Invalid attachment directory")) + + # Ignore further checks if the filename is not actually being renamed + if new_file == old_file: + return + + forbidden = ["'", '"', "#", "@", "!", "&", "^", "<", ">", ":", ";", "/", "\\", "|", "?", "*", "%", "~", "`"] + + for c in forbidden: + if c in fn: + raise ValidationError(_(f"Filename contains illegal character '{c}'")) + + if len(fn.split('.')) < 2: + raise ValidationError(_("Filename missing extension")) + + if not os.path.exists(old_file): + logger.error(f"Trying to rename attachment '{old_file}' which does not exist") + return + + if os.path.exists(new_file): + raise ValidationError(_("Attachment with this filename already exists")) + + try: + os.rename(old_file, new_file) + self.attachment.name = os.path.join(self.getSubdir(), fn) + self.save() + except: + raise ValidationError(_("Error renaming file")) + class Meta: abstract = True diff --git a/InvenTree/InvenTree/serializers.py b/InvenTree/InvenTree/serializers.py index baf08e112b..b156e39167 100644 --- a/InvenTree/InvenTree/serializers.py +++ b/InvenTree/InvenTree/serializers.py @@ -167,6 +167,18 @@ class InvenTreeModelSerializer(serializers.ModelSerializer): return self.instance + def update(self, instance, validated_data): + """ + Catch any django ValidationError, and re-throw as a DRF ValidationError + """ + + try: + instance = super().update(instance, validated_data) + except (ValidationError, DjangoValidationError) as exc: + raise ValidationError(detail=serializers.as_serializer_error(exc)) + + return instance + def run_validation(self, data=empty): """ Perform serializer validation. @@ -188,7 +200,10 @@ class InvenTreeModelSerializer(serializers.ModelSerializer): # Update instance fields for attr, value in data.items(): - setattr(instance, attr, value) + try: + setattr(instance, attr, value) + except (ValidationError, DjangoValidationError) as exc: + raise ValidationError(detail=serializers.as_serializer_error(exc)) # Run a 'full_clean' on the model. # Note that by default, DRF does *not* perform full model validation! @@ -208,6 +223,22 @@ class InvenTreeModelSerializer(serializers.ModelSerializer): return data +class InvenTreeAttachmentSerializer(InvenTreeModelSerializer): + """ + Special case of an InvenTreeModelSerializer, which handles an "attachment" model. + + The only real addition here is that we support "renaming" of the attachment file. + """ + + # The 'filename' field must be present in the serializer + filename = serializers.CharField( + label=_('Filename'), + required=False, + source='basename', + allow_blank=False, + ) + + class InvenTreeAttachmentSerializerField(serializers.FileField): """ Override the DRF native FileField serializer, diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 4543b873bd..f3c166df88 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -169,6 +169,30 @@ else: logger.exception(f"Couldn't load keyfile {key_file}") sys.exit(-1) +# The filesystem location for served static files +STATIC_ROOT = os.path.abspath( + get_setting( + 'INVENTREE_STATIC_ROOT', + CONFIG.get('static_root', None) + ) +) + +if STATIC_ROOT is None: + print("ERROR: INVENTREE_STATIC_ROOT directory not defined") + sys.exit(1) + +# The filesystem location for served static files +MEDIA_ROOT = os.path.abspath( + get_setting( + 'INVENTREE_MEDIA_ROOT', + CONFIG.get('media_root', None) + ) +) + +if MEDIA_ROOT is None: + print("ERROR: INVENTREE_MEDIA_ROOT directory is not defined") + sys.exit(1) + # List of allowed hosts (default = allow all) ALLOWED_HOSTS = CONFIG.get('allowed_hosts', ['*']) @@ -189,22 +213,12 @@ if cors_opt: # Web URL endpoint for served static files STATIC_URL = '/static/' -# The filesystem location for served static files -STATIC_ROOT = os.path.abspath( - get_setting( - 'INVENTREE_STATIC_ROOT', - CONFIG.get('static_root', '/home/inventree/data/static') - ) -) - -STATICFILES_DIRS = [ - os.path.join(BASE_DIR, 'InvenTree', 'static'), -] +STATICFILES_DIRS = [] # Translated Template settings STATICFILES_I18_PREFIX = 'i18n' STATICFILES_I18_SRC = os.path.join(BASE_DIR, 'templates', 'js', 'translated') -STATICFILES_I18_TRG = STATICFILES_DIRS[0] + '_' + STATICFILES_I18_PREFIX +STATICFILES_I18_TRG = os.path.join(BASE_DIR, 'InvenTree', 'static_i18n') STATICFILES_DIRS.append(STATICFILES_I18_TRG) STATICFILES_I18_TRG = os.path.join(STATICFILES_I18_TRG, STATICFILES_I18_PREFIX) @@ -218,19 +232,11 @@ STATIC_COLOR_THEMES_DIR = os.path.join(STATIC_ROOT, 'css', 'color-themes') # Web URL endpoint for served media files MEDIA_URL = '/media/' -# The filesystem location for served static files -MEDIA_ROOT = os.path.abspath( - get_setting( - 'INVENTREE_MEDIA_ROOT', - CONFIG.get('media_root', '/home/inventree/data/media') - ) -) - if DEBUG: logger.info("InvenTree running in DEBUG mode") -logger.info(f"MEDIA_ROOT: '{MEDIA_ROOT}'") -logger.info(f"STATIC_ROOT: '{STATIC_ROOT}'") +logger.debug(f"MEDIA_ROOT: '{MEDIA_ROOT}'") +logger.debug(f"STATIC_ROOT: '{STATIC_ROOT}'") # Application definition @@ -320,6 +326,7 @@ TEMPLATES = [ 'django.template.context_processors.i18n', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', + # Custom InvenTree context processors 'InvenTree.context.health_status', 'InvenTree.context.status_codes', 'InvenTree.context.user_roles', @@ -413,7 +420,7 @@ Configure the database backend based on the user-specified values. - The following code lets the user "mix and match" database configuration """ -logger.info("Configuring database backend:") +logger.debug("Configuring database backend:") # Extract database configuration from the config.yaml file db_config = CONFIG.get('database', {}) @@ -467,11 +474,9 @@ if db_engine in ['sqlite3', 'postgresql', 'mysql']: db_name = db_config['NAME'] db_host = db_config.get('HOST', "''") -print("InvenTree Database Configuration") -print("================================") -print(f"ENGINE: {db_engine}") -print(f"NAME: {db_name}") -print(f"HOST: {db_host}") +logger.info(f"DB_ENGINE: {db_engine}") +logger.info(f"DB_NAME: {db_name}") +logger.info(f"DB_HOST: {db_host}") DATABASES['default'] = db_config diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index adb5a41ee6..9ed478ea90 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -640,6 +640,11 @@ z-index: 9999; } +.modal-error { + border: 2px #FCC solid; + background-color: #f5f0f0; +} + .modal-header { border-bottom: 1px solid #ddd; } @@ -730,6 +735,13 @@ padding: 10px; } +.form-panel { + border-radius: 5px; + border: 1px solid #ccc; + padding: 5px; +} + + .modal input { width: 100%; } @@ -1037,6 +1049,11 @@ a.anchor { height: 30px; } +/* Force minimum width of number input fields to show at least ~5 digits */ +input[type='number']{ + min-width: 80px; +} + .search-menu { padding-top: 2rem; } diff --git a/InvenTree/InvenTree/tasks.py b/InvenTree/InvenTree/tasks.py index 24631dc9e5..5fb6960601 100644 --- a/InvenTree/InvenTree/tasks.py +++ b/InvenTree/InvenTree/tasks.py @@ -36,7 +36,7 @@ def schedule_task(taskname, **kwargs): # If this task is already scheduled, don't schedule it again # Instead, update the scheduling parameters if Schedule.objects.filter(func=taskname).exists(): - logger.info(f"Scheduled task '{taskname}' already exists - updating!") + logger.debug(f"Scheduled task '{taskname}' already exists - updating!") Schedule.objects.filter(func=taskname).update(**kwargs) else: @@ -204,6 +204,25 @@ def check_for_updates(): ) +def delete_expired_sessions(): + """ + Remove any expired user sessions from the database + """ + + try: + from django.contrib.sessions.models import Session + + # Delete any sessions that expired more than a day ago + expired = Session.objects.filter(expire_date__lt=timezone.now() - timedelta(days=1)) + + if True or expired.count() > 0: + logger.info(f"Deleting {expired.count()} expired sessions.") + expired.delete() + + except AppRegistryNotReady: + logger.info("Could not perform 'delete_expired_sessions' - App registry not ready") + + def update_exchange_rates(): """ Update currency exchange rates diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index 5c0fced884..69e3a7aed0 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -10,7 +10,8 @@ from django.db.models import BooleanField from rest_framework import serializers -from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializerField, UserSerializerBrief +from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer +from InvenTree.serializers import InvenTreeAttachmentSerializerField, UserSerializerBrief from stock.serializers import StockItemSerializerBrief from stock.serializers import LocationSerializer @@ -158,7 +159,7 @@ class BuildItemSerializer(InvenTreeModelSerializer): ] -class BuildAttachmentSerializer(InvenTreeModelSerializer): +class BuildAttachmentSerializer(InvenTreeAttachmentSerializer): """ Serializer for a BuildAttachment """ @@ -172,6 +173,7 @@ class BuildAttachmentSerializer(InvenTreeModelSerializer): 'pk', 'build', 'attachment', + 'filename', 'comment', 'upload_date', ] diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index fe716b87f2..d6b59a060d 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -369,6 +369,7 @@ loadAttachmentTable( constructForm(url, { fields: { + filename: {}, comment: {}, }, onSuccess: reloadAttachmentTable, diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 3924a516f3..cf5e44595a 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -20,7 +20,6 @@ from djmoney.contrib.exchange.models import convert_money from djmoney.contrib.exchange.exceptions import MissingRate from django.utils.translation import ugettext_lazy as _ -from django.utils.html import format_html from django.core.validators import MinValueValidator, URLValidator from django.core.exceptions import ValidationError @@ -49,55 +48,37 @@ class BaseInvenTreeSetting(models.Model): are assigned their default values """ - keys = set() - settings = [] - results = cls.objects.all() if user is not None: results = results.filter(user=user) # Query the database + settings = {} + for setting in results: if setting.key: - settings.append({ - "key": setting.key.upper(), - "value": setting.value - }) - - keys.add(setting.key.upper()) + settings[setting.key.upper()] = setting.value # Specify any "default" values which are not in the database for key in cls.GLOBAL_SETTINGS.keys(): - if key.upper() not in keys: + if key.upper() not in settings: - settings.append({ - "key": key.upper(), - "value": cls.get_setting_default(key) - }) - - # Enforce javascript formatting - for idx, setting in enumerate(settings): - - key = setting['key'] - value = setting['value'] + settings[key.upper()] = cls.get_setting_default(key) + for key, value in settings.items(): validator = cls.get_setting_validator(key) - # Convert to javascript compatible booleans if cls.validator_is_bool(validator): - value = str(value).lower() - - # Numerical values remain the same + value = InvenTree.helpers.str2bool(value) elif cls.validator_is_int(validator): - pass + try: + value = int(value) + except ValueError: + value = cls.get_setting_default(key) - # Wrap strings with quotes - else: - value = format_html("'{}'", value) - - setting["value"] = value + settings[key] = value return settings diff --git a/InvenTree/company/templates/company/detail.html b/InvenTree/company/templates/company/detail.html index 884ec6e8de..806d7f6441 100644 --- a/InvenTree/company/templates/company/detail.html +++ b/InvenTree/company/templates/company/detail.html @@ -24,19 +24,17 @@ {% endif %}
- + +
@@ -59,27 +57,25 @@ {% if roles.purchase_order.change %}
-
+
{% if roles.purchase_order.add %} - {% endif %} -
- -
+
+ + +
@@ -87,7 +83,7 @@
{% endif %} - +
@@ -274,6 +270,10 @@ {% if company.is_manufacturer %} + function reloadManufacturerPartTable() { + $('#manufacturer-part-table').bootstrapTable('refresh'); + } + $("#manufacturer-part-create").click(function () { createManufacturerPart({ @@ -285,7 +285,7 @@ }); loadManufacturerPartTable( - "#part-table", + "#manufacturer-part-table", "{% url 'api-manufacturer-part-list' %}", { params: { @@ -296,20 +296,20 @@ } ); - linkButtonsToSelection($("#manufacturer-table"), ['#table-options']); + linkButtonsToSelection($("#manufacturer-part-table"), ['#manufacturer-table-options']); - $("#multi-part-delete").click(function() { - var selections = $("#part-table").bootstrapTable("getSelections"); + $("#multi-manufacturer-part-delete").click(function() { + var selections = $("#manufacturer-part-table").bootstrapTable("getSelections"); deleteManufacturerParts(selections, { onSuccess: function() { - $("#part-table").bootstrapTable("refresh"); + $("#manufacturer-part-table").bootstrapTable("refresh"); } }); }); - $("#multi-part-order").click(function() { - var selections = $("#part-table").bootstrapTable("getSelections"); + $("#multi-manufacturer-part-order").click(function() { + var selections = $("#manufacturer-part-table").bootstrapTable("getSelections"); var parts = []; @@ -353,9 +353,9 @@ } ); - {% endif %} + linkButtonsToSelection($("#supplier-part-table"), ['#supplier-table-options']); - $("#multi-part-delete").click(function() { + $("#multi-supplier-part-delete").click(function() { var selections = $("#supplier-part-table").bootstrapTable("getSelections"); var requests = []; @@ -379,8 +379,8 @@ ); }); - $("#multi-part-order").click(function() { - var selections = $("#part-table").bootstrapTable("getSelections"); + $("#multi-supplier-part-order").click(function() { + var selections = $("#supplier-part-table").bootstrapTable("getSelections"); var parts = []; @@ -395,6 +395,8 @@ }); }); + {% endif %} + attachNavCallbacks({ name: 'company', default: 'company-stock' diff --git a/InvenTree/company/templates/company/manufacturer_part.html b/InvenTree/company/templates/company/manufacturer_part.html index 4623eb3a07..cc2dd68840 100644 --- a/InvenTree/company/templates/company/manufacturer_part.html +++ b/InvenTree/company/templates/company/manufacturer_part.html @@ -109,7 +109,7 @@ src="{% static 'img/blank_image.png' %}" {% trans "New Supplier Part" %}
- + @@ -133,7 +133,7 @@ src="{% static 'img/blank_image.png' %}" {% trans "New Parameter" %}
- + diff --git a/InvenTree/order/migrations/0049_alter_purchaseorderlineitem_unique_together.py b/InvenTree/order/migrations/0049_alter_purchaseorderlineitem_unique_together.py new file mode 100644 index 0000000000..c451e1754d --- /dev/null +++ b/InvenTree/order/migrations/0049_alter_purchaseorderlineitem_unique_together.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.4 on 2021-08-12 17:49 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('company', '0040_alter_company_currency'), + ('order', '0048_auto_20210702_2321'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='purchaseorderlineitem', + unique_together={('order', 'part', 'quantity', 'purchase_price')}, + ), + ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 248ecb277d..e55f5203ba 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -729,7 +729,7 @@ class PurchaseOrderLineItem(OrderLineItem): class Meta: unique_together = ( - ('order', 'part') + ('order', 'part', 'quantity', 'purchase_price') ) def __str__(self): diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 4a95bbb166..e97d19250a 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -14,6 +14,7 @@ from rest_framework import serializers from sql_util.utils import SubqueryCount from InvenTree.serializers import InvenTreeModelSerializer +from InvenTree.serializers import InvenTreeAttachmentSerializer from InvenTree.serializers import InvenTreeMoneySerializer from InvenTree.serializers import InvenTreeAttachmentSerializerField @@ -160,7 +161,7 @@ class POLineItemSerializer(InvenTreeModelSerializer): ] -class POAttachmentSerializer(InvenTreeModelSerializer): +class POAttachmentSerializer(InvenTreeAttachmentSerializer): """ Serializers for the PurchaseOrderAttachment model """ @@ -174,6 +175,7 @@ class POAttachmentSerializer(InvenTreeModelSerializer): 'pk', 'order', 'attachment', + 'filename', 'comment', 'upload_date', ] @@ -381,7 +383,7 @@ class SOLineItemSerializer(InvenTreeModelSerializer): ] -class SOAttachmentSerializer(InvenTreeModelSerializer): +class SOAttachmentSerializer(InvenTreeAttachmentSerializer): """ Serializers for the SalesOrderAttachment model """ @@ -395,6 +397,7 @@ class SOAttachmentSerializer(InvenTreeModelSerializer): 'pk', 'order', 'attachment', + 'filename', 'comment', 'upload_date', ] diff --git a/InvenTree/order/templates/order/order_wizard/match_parts.html b/InvenTree/order/templates/order/order_wizard/match_parts.html index e0f030bad5..4f84d205ee 100644 --- a/InvenTree/order/templates/order/order_wizard/match_parts.html +++ b/InvenTree/order/templates/order/order_wizard/match_parts.html @@ -115,7 +115,7 @@ {{ block.super }} $('.bomselect').select2({ - dropdownAutoWidth: true, + width: '100%', matcher: partialMatcher, }); diff --git a/InvenTree/order/templates/order/purchase_order_detail.html b/InvenTree/order/templates/order/purchase_order_detail.html index b05bfa7cc2..586ce73f14 100644 --- a/InvenTree/order/templates/order/purchase_order_detail.html +++ b/InvenTree/order/templates/order/purchase_order_detail.html @@ -122,6 +122,7 @@ constructForm(url, { fields: { + filename: {}, comment: {}, }, onSuccess: reloadAttachmentTable, @@ -327,7 +328,7 @@ $("#po-table").inventreeTable({ { sortable: true, sortName: 'part__MPN', - field: 'supplier_part_detail.MPN', + field: 'supplier_part_detail.manufacturer_part_detail.MPN', title: '{% trans "MPN" %}', formatter: function(value, row, index, field) { if (row.supplier_part_detail && row.supplier_part_detail.manufacturer_part) { diff --git a/InvenTree/order/templates/order/sales_order_detail.html b/InvenTree/order/templates/order/sales_order_detail.html index 277c1f4278..30799e2296 100644 --- a/InvenTree/order/templates/order/sales_order_detail.html +++ b/InvenTree/order/templates/order/sales_order_detail.html @@ -112,6 +112,7 @@ constructForm(url, { fields: { + filename: {}, comment: {}, }, onSuccess: reloadAttachmentTable, diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 789ba9b9b7..34441286ff 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -9,12 +9,14 @@ from django.conf.urls import url, include from django.urls import reverse from django.http import JsonResponse from django.db.models import Q, F, Count, Min, Max, Avg +from django.db import transaction from django.utils.translation import ugettext_lazy as _ from rest_framework import status from rest_framework.response import Response from rest_framework import filters, serializers from rest_framework import generics +from rest_framework.exceptions import ValidationError from django_filters.rest_framework import DjangoFilterBackend from django_filters import rest_framework as rest_filters @@ -23,7 +25,7 @@ from djmoney.money import Money from djmoney.contrib.exchange.models import convert_money from djmoney.contrib.exchange.exceptions import MissingRate -from decimal import Decimal +from decimal import Decimal, InvalidOperation from .models import Part, PartCategory, BomItem from .models import PartParameter, PartParameterTemplate @@ -31,7 +33,10 @@ from .models import PartAttachment, PartTestTemplate from .models import PartSellPriceBreak, PartInternalPriceBreak from .models import PartCategoryParameterTemplate -from stock.models import StockItem +from company.models import Company, ManufacturerPart, SupplierPart + +from stock.models import StockItem, StockLocation + from common.models import InvenTreeSetting from build.models import Build @@ -630,6 +635,7 @@ class PartList(generics.ListCreateAPIView): else: return Response(data) + @transaction.atomic def create(self, request, *args, **kwargs): """ We wish to save the user who created this part! @@ -637,6 +643,8 @@ class PartList(generics.ListCreateAPIView): Note: Implementation copied from DRF class CreateModelMixin """ + # TODO: Unit tests for this function! + serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -680,21 +688,97 @@ class PartList(generics.ListCreateAPIView): pass # Optionally create initial stock item - try: - initial_stock = Decimal(request.data.get('initial_stock', 0)) + initial_stock = str2bool(request.data.get('initial_stock', False)) - if initial_stock > 0 and part.default_location is not None: + if initial_stock: + try: - stock_item = StockItem( + initial_stock_quantity = Decimal(request.data.get('initial_stock_quantity', '')) + + if initial_stock_quantity <= 0: + raise ValidationError({ + 'initial_stock_quantity': [_('Must be greater than zero')], + }) + except (ValueError, InvalidOperation): # Invalid quantity provided + raise ValidationError({ + 'initial_stock_quantity': [_('Must be a valid quantity')], + }) + + initial_stock_location = request.data.get('initial_stock_location', None) + + try: + initial_stock_location = StockLocation.objects.get(pk=initial_stock_location) + except (ValueError, StockLocation.DoesNotExist): + initial_stock_location = None + + if initial_stock_location is None: + if part.default_location is not None: + initial_stock_location = part.default_location + else: + raise ValidationError({ + 'initial_stock_location': [_('Specify location for initial part stock')], + }) + + stock_item = StockItem( + part=part, + quantity=initial_stock_quantity, + location=initial_stock_location, + ) + + stock_item.save(user=request.user) + + # Optionally add manufacturer / supplier data to the part + if part.purchaseable and str2bool(request.data.get('add_supplier_info', False)): + + try: + manufacturer = Company.objects.get(pk=request.data.get('manufacturer', None)) + except: + manufacturer = None + + try: + supplier = Company.objects.get(pk=request.data.get('supplier', None)) + except: + supplier = None + + mpn = str(request.data.get('MPN', '')).strip() + sku = str(request.data.get('SKU', '')).strip() + + # Construct a manufacturer part + if manufacturer or mpn: + if not manufacturer: + raise ValidationError({ + 'manufacturer': [_("This field is required")] + }) + if not mpn: + raise ValidationError({ + 'MPN': [_("This field is required")] + }) + + manufacturer_part = ManufacturerPart.objects.create( part=part, - quantity=initial_stock, - location=part.default_location, + manufacturer=manufacturer, + MPN=mpn ) + else: + # No manufacturer part data specified + manufacturer_part = None - stock_item.save(user=request.user) + if supplier or sku: + if not supplier: + raise ValidationError({ + 'supplier': [_("This field is required")] + }) + if not sku: + raise ValidationError({ + 'SKU': [_("This field is required")] + }) - except: - pass + SupplierPart.objects.create( + part=part, + supplier=supplier, + SKU=sku, + manufacturer_part=manufacturer_part, + ) headers = self.get_success_headers(serializer.data) diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index e2c8c3fa4d..c2d515cf32 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -1,6 +1,7 @@ """ JSON serializers for Part app """ + import imghdr from decimal import Decimal @@ -16,7 +17,9 @@ from djmoney.contrib.django_rest_framework import MoneyField from InvenTree.serializers import (InvenTreeAttachmentSerializerField, InvenTreeImageSerializerField, InvenTreeModelSerializer, + InvenTreeAttachmentSerializer, InvenTreeMoneySerializer) + from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus from stock.models import StockItem @@ -51,7 +54,7 @@ class CategorySerializer(InvenTreeModelSerializer): ] -class PartAttachmentSerializer(InvenTreeModelSerializer): +class PartAttachmentSerializer(InvenTreeAttachmentSerializer): """ Serializer for the PartAttachment class """ @@ -65,6 +68,7 @@ class PartAttachmentSerializer(InvenTreeModelSerializer): 'pk', 'part', 'attachment', + 'filename', 'comment', 'upload_date', ] diff --git a/InvenTree/part/templates/part/category.html b/InvenTree/part/templates/part/category.html index b149fd28ed..21c5d0061e 100644 --- a/InvenTree/part/templates/part/category.html +++ b/InvenTree/part/templates/part/category.html @@ -132,12 +132,13 @@ {% endif %}
- +
@@ -276,6 +277,7 @@ constructForm('{% url "api-part-list" %}', { method: 'POST', fields: fields, + groups: partGroups(), title: '{% trans "Create Part" %}', onSuccess: function(data) { // Follow the new part @@ -336,4 +338,4 @@ default: 'part-stock' }); -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index 846320b8e1..5e398718f5 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -289,7 +289,7 @@ {% trans "New Supplier Part" %}
- + @@ -312,7 +312,7 @@ {% trans "New Manufacturer Part" %}
- + @@ -868,6 +868,7 @@ constructForm(url, { fields: { + filename: {}, comment: {}, }, title: '{% trans "Edit Attachment" %}', diff --git a/InvenTree/part/templates/part/import_wizard/part_upload.html b/InvenTree/part/templates/part/import_wizard/part_upload.html index 676053bbe5..b898cfbc98 100644 --- a/InvenTree/part/templates/part/import_wizard/part_upload.html +++ b/InvenTree/part/templates/part/import_wizard/part_upload.html @@ -1,8 +1,24 @@ -{% extends "base.html" %} +{% extends "part/part_app_base.html" %} {% load inventree_extras %} {% load i18n %} {% load static %} +{% block menubar %} + +{% endblock %} + {% block content %}
@@ -54,4 +70,9 @@ {% block js_ready %} {{ block.super }} +enableNavbar({ + label: 'part', + toggleId: '#part-menu-toggle', +}); + {% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templatetags/inventree_extras.py b/InvenTree/part/templatetags/inventree_extras.py index b12a59f136..3b88deb504 100644 --- a/InvenTree/part/templatetags/inventree_extras.py +++ b/InvenTree/part/templatetags/inventree_extras.py @@ -6,6 +6,7 @@ over and above the built-in Django tags. import os import sys +from django.utils.html import format_html from django.utils.translation import ugettext_lazy as _ from django.conf import settings as djangosettings @@ -262,6 +263,26 @@ def get_available_themes(*args, **kwargs): return themes +@register.simple_tag() +def primitive_to_javascript(primitive): + """ + Convert a python primitive to a javascript primitive. + + e.g. True -> true + 'hello' -> '"hello"' + """ + + if type(primitive) is bool: + return str(primitive).lower() + + elif type(primitive) in [int, float]: + return primitive + + else: + # Wrap with quotes + return format_html("'{}'", primitive) + + @register.filter def keyvalue(dict, key): """ diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index bbd73b73e0..ebef21b84b 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -129,6 +129,7 @@ class PartAPITest(InvenTreeAPITestCase): 'location', 'bom', 'test_templates', + 'company', ] roles = [ @@ -465,6 +466,128 @@ class PartAPITest(InvenTreeAPITestCase): self.assertFalse(response.data['active']) self.assertFalse(response.data['purchaseable']) + def test_initial_stock(self): + """ + Tests for initial stock quantity creation + """ + + url = reverse('api-part-list') + + # Track how many parts exist at the start of this test + n = Part.objects.count() + + # Set up required part data + data = { + 'category': 1, + 'name': "My lil' test part", + 'description': 'A part with which to test', + } + + # Signal that we want to add initial stock + data['initial_stock'] = True + + # Post without a quantity + response = self.post(url, data, expected_code=400) + self.assertIn('initial_stock_quantity', response.data) + + # Post with an invalid quantity + data['initial_stock_quantity'] = "ax" + response = self.post(url, data, expected_code=400) + self.assertIn('initial_stock_quantity', response.data) + + # Post with a negative quantity + data['initial_stock_quantity'] = -1 + response = self.post(url, data, expected_code=400) + self.assertIn('Must be greater than zero', response.data['initial_stock_quantity']) + + # Post with a valid quantity + data['initial_stock_quantity'] = 12345 + + response = self.post(url, data, expected_code=400) + self.assertIn('initial_stock_location', response.data) + + # Check that the number of parts has not increased (due to form failures) + self.assertEqual(Part.objects.count(), n) + + # Now, set a location + data['initial_stock_location'] = 1 + + response = self.post(url, data, expected_code=201) + + # Check that the part has been created + self.assertEqual(Part.objects.count(), n + 1) + + pk = response.data['pk'] + + new_part = Part.objects.get(pk=pk) + + self.assertEqual(new_part.total_stock, 12345) + + def test_initial_supplier_data(self): + """ + Tests for initial creation of supplier / manufacturer data + """ + + url = reverse('api-part-list') + + n = Part.objects.count() + + # Set up initial part data + data = { + 'category': 1, + 'name': 'Buy Buy Buy', + 'description': 'A purchaseable part', + 'purchaseable': True, + } + + # Signal that we wish to create initial supplier data + data['add_supplier_info'] = True + + # Specify MPN but not manufacturer + data['MPN'] = 'MPN-123' + + response = self.post(url, data, expected_code=400) + self.assertIn('manufacturer', response.data) + + # Specify manufacturer but not MPN + del data['MPN'] + data['manufacturer'] = 1 + response = self.post(url, data, expected_code=400) + self.assertIn('MPN', response.data) + + # Specify SKU but not supplier + del data['manufacturer'] + data['SKU'] = 'SKU-123' + response = self.post(url, data, expected_code=400) + self.assertIn('supplier', response.data) + + # Specify supplier but not SKU + del data['SKU'] + data['supplier'] = 1 + response = self.post(url, data, expected_code=400) + self.assertIn('SKU', response.data) + + # Check that no new parts have been created + self.assertEqual(Part.objects.count(), n) + + # Now, fully specify the details + data['SKU'] = 'SKU-123' + data['supplier'] = 3 + data['MPN'] = 'MPN-123' + data['manufacturer'] = 6 + + response = self.post(url, data, expected_code=201) + + self.assertEqual(Part.objects.count(), n + 1) + + pk = response.data['pk'] + + new_part = Part.objects.get(pk=pk) + + # Check that there is a new manufacturer part *and* a new supplier part + self.assertEqual(new_part.supplier_parts.count(), 1) + self.assertEqual(new_part.manufacturer_parts.count(), 1) + class PartDetailTests(InvenTreeAPITestCase): """ diff --git a/InvenTree/part/test_bom_export.py b/InvenTree/part/test_bom_export.py index 13ec3a179e..f8ed5ee305 100644 --- a/InvenTree/part/test_bom_export.py +++ b/InvenTree/part/test_bom_export.py @@ -2,6 +2,8 @@ Unit testing for BOM export functionality """ +import csv + from django.test import TestCase from django.urls import reverse @@ -47,13 +49,63 @@ class BomExportTest(TestCase): self.url = reverse('bom-download', kwargs={'pk': 100}) + def test_bom_template(self): + """ + Test that the BOM template can be downloaded from the server + """ + + url = reverse('bom-upload-template') + + # Download an XLS template + response = self.client.get(url, data={'format': 'xls'}) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.headers['Content-Disposition'], + 'attachment; filename="InvenTree_BOM_Template.xls"' + ) + + # Return a simple CSV template + response = self.client.get(url, data={'format': 'csv'}) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.headers['Content-Disposition'], + 'attachment; filename="InvenTree_BOM_Template.csv"' + ) + + filename = '_tmp.csv' + + with open(filename, 'wb') as f: + f.write(response.getvalue()) + + with open(filename, 'r') as f: + reader = csv.reader(f, delimiter=',') + + for line in reader: + headers = line + break + + expected = [ + 'part_id', + 'part_ipn', + 'part_name', + 'quantity', + 'optional', + 'overage', + 'reference', + 'note', + 'inherited', + 'allow_variants', + ] + + # Ensure all the expected headers are in the provided file + for header in expected: + self.assertTrue(header in headers) + def test_export_csv(self): """ Test BOM download in CSV format """ - print("URL", self.url) - params = { 'file_format': 'csv', 'cascade': True, @@ -70,6 +122,47 @@ class BomExportTest(TestCase): content = response.headers['Content-Disposition'] self.assertEqual(content, 'attachment; filename="BOB | Bob | A2_BOM.csv"') + filename = '_tmp.csv' + + with open(filename, 'wb') as f: + f.write(response.getvalue()) + + # Read the file + with open(filename, 'r') as f: + reader = csv.reader(f, delimiter=',') + + for line in reader: + headers = line + break + + expected = [ + 'level', + 'bom_id', + 'parent_part_id', + 'parent_part_ipn', + 'parent_part_name', + 'part_id', + 'part_ipn', + 'part_name', + 'part_description', + 'sub_assembly', + 'quantity', + 'optional', + 'overage', + 'reference', + 'note', + 'inherited', + 'allow_variants', + 'Default Location', + 'Available Stock', + ] + + for header in expected: + self.assertTrue(header in headers) + + for header in headers: + self.assertTrue(header in expected) + def test_export_xls(self): """ Test BOM download in XLS format diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 41dc959f02..e7ec2fd291 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -25,7 +25,7 @@ import common.models from company.serializers import SupplierPartSerializer from part.serializers import PartBriefSerializer from InvenTree.serializers import UserSerializerBrief, InvenTreeModelSerializer -from InvenTree.serializers import InvenTreeAttachmentSerializerField +from InvenTree.serializers import InvenTreeAttachmentSerializer, InvenTreeAttachmentSerializerField class LocationBriefSerializer(InvenTreeModelSerializer): @@ -253,7 +253,7 @@ class LocationSerializer(InvenTreeModelSerializer): ] -class StockItemAttachmentSerializer(InvenTreeModelSerializer): +class StockItemAttachmentSerializer(InvenTreeAttachmentSerializer): """ Serializer for StockItemAttachment model """ def __init__(self, *args, **kwargs): @@ -277,6 +277,7 @@ class StockItemAttachmentSerializer(InvenTreeModelSerializer): 'pk', 'stock_item', 'attachment', + 'filename', 'comment', 'upload_date', 'user', diff --git a/InvenTree/stock/templates/stock/item.html b/InvenTree/stock/templates/stock/item.html index 19295d1198..0ac9c285a6 100644 --- a/InvenTree/stock/templates/stock/item.html +++ b/InvenTree/stock/templates/stock/item.html @@ -215,6 +215,7 @@ constructForm(url, { fields: { + filename: {}, comment: {}, }, title: '{% trans "Edit Attachment" %}', diff --git a/InvenTree/templates/js/dynamic/settings.js b/InvenTree/templates/js/dynamic/settings.js index ad4e297c4a..60172ead64 100644 --- a/InvenTree/templates/js/dynamic/settings.js +++ b/InvenTree/templates/js/dynamic/settings.js @@ -2,16 +2,17 @@ // InvenTree settings {% user_settings request.user as USER_SETTINGS %} -{% global_settings as GLOBAL_SETTINGS %} var user_settings = { - {% for setting in USER_SETTINGS %} - {{ setting.key }}: {{ setting.value }}, + {% for key, value in USER_SETTINGS.items %} + {{ key }}: {% primitive_to_javascript value %}, {% endfor %} }; +{% global_settings as GLOBAL_SETTINGS %} + var global_settings = { - {% for setting in GLOBAL_SETTINGS %} - {{ setting.key }}: {{ setting.value }}, + {% for key, value in GLOBAL_SETTINGS.items %} + {{ key }}: {% primitive_to_javascript value %}, {% endfor %} }; \ No newline at end of file diff --git a/InvenTree/templates/js/translated/attachment.js b/InvenTree/templates/js/translated/attachment.js index 4b9d522a59..bffe3d9995 100644 --- a/InvenTree/templates/js/translated/attachment.js +++ b/InvenTree/templates/js/translated/attachment.js @@ -42,9 +42,32 @@ function loadAttachmentTable(url, options) { title: '{% trans "File" %}', formatter: function(value, row) { - var split = value.split('/'); + var icon = 'fa-file-alt'; - return renderLink(split[split.length - 1], value); + var fn = value.toLowerCase(); + + if (fn.endsWith('.pdf')) { + icon = 'fa-file-pdf'; + } else if (fn.endsWith('.xls') || fn.endsWith('.xlsx')) { + icon = 'fa-file-excel'; + } else if (fn.endsWith('.doc') || fn.endsWith('.docx')) { + icon = 'fa-file-word'; + } else { + var images = ['.png', '.jpg', '.bmp', '.gif', '.svg', '.tif']; + + images.forEach(function (suffix) { + if (fn.endsWith(suffix)) { + icon = 'fa-file-image'; + } + }); + } + + var split = value.split('/'); + var filename = split[split.length - 1]; + + var html = ` ${filename}`; + + return renderLink(html, value); } }, { diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js index 34a6206ac9..37a3eb23b0 100644 --- a/InvenTree/templates/js/translated/bom.js +++ b/InvenTree/templates/js/translated/bom.js @@ -252,7 +252,7 @@ function loadBomTable(table, options) { sortable: true, formatter: function(value, row, index, field) { - var url = `/part/${row.sub_part_detail.pk}/stock/`; + var url = `/part/${row.sub_part_detail.pk}/?display=stock`; var text = value; if (value == null || value <= 0) { diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 4b41623fbf..904053a423 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -264,6 +264,10 @@ function constructForm(url, options) { // Default HTTP method options.method = options.method || 'PATCH'; + // Default "groups" definition + options.groups = options.groups || {}; + options.current_group = null; + // Construct an "empty" data object if not provided if (!options.data) { options.data = {}; @@ -362,6 +366,13 @@ function constructFormBody(fields, options) { } } + // Initialize an "empty" field for each specified field + for (field in displayed_fields) { + if (!(field in fields)) { + fields[field] = {}; + } + } + // Provide each field object with its own name for(field in fields) { fields[field].name = field; @@ -379,52 +390,18 @@ function constructFormBody(fields, options) { // Override existing query filters (if provided!) fields[field].filters = Object.assign(fields[field].filters || {}, field_options.filters); - // TODO: Refactor the following code with Object.assign (see above) + for (var opt in field_options) { - // "before" and "after" renders - fields[field].before = field_options.before; - fields[field].after = field_options.after; + var val = field_options[opt]; - // Secondary modal options - fields[field].secondary = field_options.secondary; - - // Edit callback - fields[field].onEdit = field_options.onEdit; - - fields[field].multiline = field_options.multiline; - - // Custom help_text - if (field_options.help_text) { - fields[field].help_text = field_options.help_text; - } - - // Custom label - if (field_options.label) { - fields[field].label = field_options.label; - } - - // Custom placeholder - if (field_options.placeholder) { - fields[field].placeholder = field_options.placeholder; - } - - // Choices - if (field_options.choices) { - fields[field].choices = field_options.choices; - } - - // Field prefix - if (field_options.prefix) { - fields[field].prefix = field_options.prefix; - } else if (field_options.icon) { - // Specify icon like 'fa-user' - fields[field].prefix = ``; - } - - fields[field].hidden = field_options.hidden; - - if (field_options.read_only != null) { - fields[field].read_only = field_options.read_only; + if (opt == 'filters') { + // ignore filters (see above) + } else if (opt == 'icon') { + // Specify custom icon + fields[field].prefix = ``; + } else { + fields[field][opt] = field_options[opt]; + } } } } @@ -465,8 +442,10 @@ function constructFormBody(fields, options) { html += constructField(name, field, options); } - // TODO: Dynamically create the modals, - // so that we can have an infinite number of stacks! + if (options.current_group) { + // Close out the current group + html += `
`; + } // Create a new modal if one does not exists if (!options.modal) { @@ -535,6 +514,11 @@ function constructFormBody(fields, options) { submitFormData(fields, options); } }); + + initializeGroups(fields, options); + + // Scroll to the top + $(options.modal).find('.modal-form-content-wrapper').scrollTop(0); } @@ -860,9 +844,12 @@ function handleFormErrors(errors, fields, options) { var non_field_errors = $(options.modal).find('#non-field-errors'); + // TODO: Display the JSON error text when hovering over the "info" icon non_field_errors.append( `
{% trans "Form errors exist" %} + +
` ); @@ -883,6 +870,8 @@ function handleFormErrors(errors, fields, options) { } } + var first_error_field = null; + for (field_name in errors) { // Add the 'has-error' class @@ -892,6 +881,10 @@ function handleFormErrors(errors, fields, options) { var field_errors = errors[field_name]; + if (field_errors && !first_error_field && isFieldVisible(field_name, options)) { + first_error_field = field_name; + } + // Add an entry for each returned error message for (var idx = field_errors.length-1; idx >= 0; idx--) { @@ -905,6 +898,24 @@ function handleFormErrors(errors, fields, options) { field_dom.append(html); } } + + if (first_error_field) { + // Ensure that the field in question is visible + document.querySelector(`#div_id_${field_name}`).scrollIntoView({ + behavior: 'smooth', + }); + } else { + // Scroll to the top of the form + $(options.modal).find('.modal-form-content-wrapper').scrollTop(0); + } + + $(options.modal).find('.modal-content').addClass('modal-error'); +} + + +function isFieldVisible(field, options) { + + return $(options.modal).find(`#div_id_${field}`).is(':visible'); } @@ -932,7 +943,10 @@ function addFieldCallbacks(fields, options) { function addFieldCallback(name, field, options) { $(options.modal).find(`#id_${name}`).change(function() { - field.onEdit(name, field, options); + + var value = getFormFieldValue(name, field, options); + + field.onEdit(value, name, field, options); }); } @@ -960,6 +974,71 @@ function addClearCallback(name, field, options) { } +// Initialize callbacks and initial states for groups +function initializeGroups(fields, options) { + + var modal = options.modal; + + // Callback for when the group is expanded + $(modal).find('.form-panel-content').on('show.bs.collapse', function() { + + var panel = $(this).closest('.form-panel'); + var group = panel.attr('group'); + + var icon = $(modal).find(`#group-icon-${group}`); + + icon.removeClass('fa-angle-right'); + icon.addClass('fa-angle-up'); + }); + + // Callback for when the group is collapsed + $(modal).find('.form-panel-content').on('hide.bs.collapse', function() { + + var panel = $(this).closest('.form-panel'); + var group = panel.attr('group'); + + var icon = $(modal).find(`#group-icon-${group}`); + + icon.removeClass('fa-angle-up'); + icon.addClass('fa-angle-right'); + }); + + // Set initial state of each specified group + for (var group in options.groups) { + + var group_options = options.groups[group]; + + if (group_options.collapsed) { + $(modal).find(`#form-panel-content-${group}`).collapse("hide"); + } else { + $(modal).find(`#form-panel-content-${group}`).collapse("show"); + } + + if (group_options.hidden) { + hideFormGroup(group, options); + } + } +} + +// Hide a form group +function hideFormGroup(group, options) { + $(options.modal).find(`#form-panel-${group}`).hide(); +} + +// Show a form group +function showFormGroup(group, options) { + $(options.modal).find(`#form-panel-${group}`).show(); +} + +function setFormGroupVisibility(group, vis, options) { + if (vis) { + showFormGroup(group, options); + } else { + hideFormGroup(group, options); + } +} + + function initializeRelatedFields(fields, options) { var field_names = options.field_names; @@ -1353,6 +1432,8 @@ function renderModelData(name, model, data, parameters, options) { */ function constructField(name, parameters, options) { + var html = ''; + // Shortcut for simple visual fields if (parameters.type == 'candy') { return constructCandyInput(name, parameters, options); @@ -1365,13 +1446,58 @@ function constructField(name, parameters, options) { return constructHiddenInput(name, parameters, options); } + // Are we ending a group? + if (options.current_group && parameters.group != options.current_group) { + html += `
`; + + // Null out the current "group" so we can start a new one + options.current_group = null; + } + + // Are we starting a new group? + if (parameters.group) { + + var group = parameters.group; + + var group_options = options.groups[group] || {}; + + // Are we starting a new group? + // Add HTML for the start of a separate panel + if (parameters.group != options.current_group) { + + html += ` +
+
`; + if (group_options.collapsible) { + html += ` + +
+ `; + } + + // Keep track of the group we are in + options.current_group = group; + } + var form_classes = 'form-group'; if (parameters.errors) { form_classes += ' has-error'; } - - var html = ''; // Optional content to render before the field if (parameters.before) { @@ -1428,13 +1554,14 @@ function constructField(name, parameters, options) { html += `
`; // input-group } - // Div for error messages - html += `
`; - if (parameters.help_text) { html += constructHelpText(name, parameters, options); } + // Div for error messages + html += `
`; + + html += `
`; // controls html += `
`; // form-group @@ -1599,6 +1726,10 @@ function constructInputOptions(name, classes, type, parameters) { opts.push(`placeholder='${parameters.placeholder}'`); } + if (parameters.type == 'boolean') { + opts.push(`style='display: inline-block; width: 20px; margin-right: 20px;'`); + } + if (parameters.multiline) { return ``; } else { @@ -1772,7 +1903,13 @@ function constructCandyInput(name, parameters, options) { */ function constructHelpText(name, parameters, options) { - var html = `
${parameters.help_text}
`; + var style = ''; + + if (parameters.type == 'boolean') { + style = `style='display: inline-block; margin-left: 25px' `; + } + + var html = `
${parameters.help_text}
`; return html; } \ No newline at end of file diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index 3d8a21c66a..4ed631fe61 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -13,6 +13,31 @@ function yesNoLabel(value) { } } + +function partGroups(options={}) { + + return { + attributes: { + title: '{% trans "Part Attributes" %}', + collapsible: true, + }, + create: { + title: '{% trans "Part Creation Options" %}', + collapsible: true, + }, + duplicate: { + title: '{% trans "Part Duplication Options" %}', + collapsible: true, + }, + supplier: { + title: '{% trans "Supplier Options" %}', + collapsible: true, + hidden: !global_settings.PART_PURCHASEABLE, + } + } + +} + // Construct fieldset for part forms function partFields(options={}) { @@ -48,36 +73,44 @@ function partFields(options={}) { minimum_stock: { icon: 'fa-boxes', }, - attributes: { - type: 'candy', - html: `

{% trans "Part Attributes" %}


` - }, component: { value: global_settings.PART_COMPONENT, + group: 'attributes', }, assembly: { value: global_settings.PART_ASSEMBLY, + group: 'attributes', }, is_template: { value: global_settings.PART_TEMPLATE, + group: 'attributes', }, trackable: { value: global_settings.PART_TRACKABLE, + group: 'attributes', }, purchaseable: { value: global_settings.PART_PURCHASEABLE, + group: 'attributes', + onEdit: function(value, name, field, options) { + setFormGroupVisibility('supplier', value, options); + } }, salable: { value: global_settings.PART_SALABLE, + group: 'attributes', }, virtual: { value: global_settings.PART_VIRTUAL, + group: 'attributes', }, }; // If editing a part, we can set the "active" status if (options.edit) { - fields.active = {}; + fields.active = { + group: 'attributes' + }; } // Pop expiry field @@ -91,16 +124,32 @@ function partFields(options={}) { // No supplier parts available yet delete fields["default_supplier"]; - fields.create = { - type: 'candy', - html: `

{% trans "Part Creation Options" %}


`, - }; - if (global_settings.PART_CREATE_INITIAL) { + fields.initial_stock = { + type: 'boolean', + label: '{% trans "Create Initial Stock" %}', + help_text: '{% trans "Create an initial stock item for this part" %}', + group: 'create', + }; + + fields.initial_stock_quantity = { type: 'decimal', + value: 1, label: '{% trans "Initial Stock Quantity" %}', - help_text: '{% trans "Initialize part stock with specified quantity" %}', + help_text: '{% trans "Specify initial stock quantity for this part" %}', + group: 'create', + }; + + // TODO - Allow initial location of stock to be specified + fields.initial_stock_location = { + label: '{% trans "Location" %}', + help_text: '{% trans "Select destination stock location" %}', + type: 'related field', + required: true, + api_url: `/api/stock/location/`, + model: 'stocklocation', + group: 'create', }; } @@ -109,21 +158,65 @@ function partFields(options={}) { label: '{% trans "Copy Category Parameters" %}', help_text: '{% trans "Copy parameter templates from selected part category" %}', value: global_settings.PART_CATEGORY_PARAMETERS, + group: 'create', }; + + // Supplier options + fields.add_supplier_info = { + type: 'boolean', + label: '{% trans "Add Supplier Data" %}', + help_text: '{% trans "Create initial supplier data for this part" %}', + group: 'supplier', + }; + + fields.supplier = { + type: 'related field', + model: 'company', + label: '{% trans "Supplier" %}', + help_text: '{% trans "Select supplier" %}', + filters: { + 'is_supplier': true, + }, + api_url: '{% url "api-company-list" %}', + group: 'supplier', + }; + + fields.SKU = { + type: 'string', + label: '{% trans "SKU" %}', + help_text: '{% trans "Supplier stock keeping unit" %}', + group: 'supplier', + }; + + fields.manufacturer = { + type: 'related field', + model: 'company', + label: '{% trans "Manufacturer" %}', + help_text: '{% trans "Select manufacturer" %}', + filters: { + 'is_manufacturer': true, + }, + api_url: '{% url "api-company-list" %}', + group: 'supplier', + }; + + fields.MPN = { + type: 'string', + label: '{% trans "MPN" %}', + help_text: '{% trans "Manufacturer Part Number" %}', + group: 'supplier', + }; + } // Additional fields when "duplicating" a part if (options.duplicate) { - fields.duplicate = { - type: 'candy', - html: `

{% trans "Part Duplication Options" %}


`, - }; - fields.copy_from = { type: 'integer', hidden: true, value: options.duplicate, + group: 'duplicate', }, fields.copy_image = { @@ -131,6 +224,7 @@ function partFields(options={}) { label: '{% trans "Copy Image" %}', help_text: '{% trans "Copy image from original part" %}', value: true, + group: 'duplicate', }, fields.copy_bom = { @@ -138,6 +232,7 @@ function partFields(options={}) { label: '{% trans "Copy BOM" %}', help_text: '{% trans "Copy bill of materials from original part" %}', value: global_settings.PART_COPY_BOM, + group: 'duplicate', }; fields.copy_parameters = { @@ -145,6 +240,7 @@ function partFields(options={}) { label: '{% trans "Copy Parameters" %}', help_text: '{% trans "Copy parameter data from original part" %}', value: global_settings.PART_COPY_PARAMETERS, + group: 'duplicate', }; } @@ -191,8 +287,11 @@ function editPart(pk, options={}) { edit: true }); + var groups = partGroups({}); + constructForm(url, { fields: fields, + groups: partGroups(), title: '{% trans "Edit Part" %}', reload: true, }); @@ -221,6 +320,7 @@ function duplicatePart(pk, options={}) { constructForm('{% url "api-part-list" %}', { method: 'POST', fields: fields, + groups: partGroups(), title: '{% trans "Duplicate Part" %}', data: data, onSuccess: function(data) { @@ -400,7 +500,7 @@ function loadPartVariantTable(table, partId, options={}) { field: 'in_stock', title: '{% trans "Stock" %}', formatter: function(value, row) { - return renderLink(value, `/part/${row.pk}/stock/`); + return renderLink(value, `/part/${row.pk}/?display=stock`); } } ]; @@ -903,6 +1003,18 @@ function loadPartTable(table, url, options={}) { }); }); + $('#multi-part-print-label').click(function() { + var selections = $(table).bootstrapTable('getSelections'); + + var items = []; + + selections.forEach(function(item) { + items.push(item.pk); + }); + + printPartLabels(items); + }); + $('#multi-part-export').click(function() { var selections = $(table).bootstrapTable("getSelections"); diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index f489b45948..d722f3bff8 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -1066,7 +1066,7 @@ function loadStockTable(table, options) { return '-'; } - var link = `/supplier-part/${row.supplier_part}/stock/`; + var link = `/supplier-part/${row.supplier_part}/?display=stock`; var text = ''; diff --git a/docker/Dockerfile b/docker/Dockerfile index 8f57d8a18c..e4ebbc1b4b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -35,51 +35,48 @@ ENV INVENTREE_SECRET_KEY_FILE="${INVENTREE_DATA_DIR}/secret_key.txt" ENV INVENTREE_GUNICORN_WORKERS="4" ENV INVENTREE_BACKGROUND_WORKERS="4" -# Default web server port is 8000 -ENV INVENTREE_WEB_PORT="8000" +# Default web server address:port +ENV INVENTREE_WEB_ADDR=0.0.0.0 +ENV INVENTREE_WEB_PORT=8000 LABEL org.label-schema.schema-version="1.0" \ org.label-schema.build-date=${DATE} \ org.label-schema.vendor="inventree" \ org.label-schema.name="inventree/inventree" \ org.label-schema.url="https://hub.docker.com/r/inventree/inventree" \ - org.label-schema.version=${INVENTREE_VERSION} \ - org.label-schema.vcs-url=${INVENTREE_REPO} \ - org.label-schema.vcs-branch=${BRANCH} \ - org.label-schema.vcs-ref=${COMMIT} + org.label-schema.vcs-url=${INVENTREE_GIT_REPO} \ + org.label-schema.vcs-branch=${INVENTREE_GIT_BRANCH} \ + org.label-schema.vcs-ref=${INVENTREE_GIT_TAG} # Create user account RUN addgroup -S inventreegroup && adduser -S inventree -G inventreegroup -WORKDIR ${INVENTREE_HOME} - # Install required system packages RUN apk add --no-cache git make bash \ gcc libgcc g++ libstdc++ \ libjpeg-turbo libjpeg-turbo-dev jpeg jpeg-dev \ libffi libffi-dev \ - zlib zlib-dev + zlib zlib-dev \ + # Cairo deps for WeasyPrint (these will be deprecated once WeasyPrint drops cairo requirement) + cairo cairo-dev pango pango-dev \ + # Fonts + fontconfig ttf-droid ttf-liberation ttf-dejavu ttf-opensans ttf-ubuntu-font-family font-croscore font-noto \ + # Core python + python3 python3-dev py3-pip \ + # SQLite support + sqlite \ + # PostgreSQL support + postgresql postgresql-contrib postgresql-dev libpq \ + # MySQL/MariaDB support + mariadb-connector-c mariadb-dev mariadb-client -# Cairo deps for WeasyPrint (these will be deprecated once WeasyPrint drops cairo requirement) -RUN apk add --no-cache cairo cairo-dev pango pango-dev -RUN apk add --no-cache fontconfig ttf-droid ttf-liberation ttf-dejavu ttf-opensans ttf-ubuntu-font-family font-croscore font-noto - -# Python -RUN apk add --no-cache python3 python3-dev py3-pip - -# SQLite support -RUN apk add --no-cache sqlite - -# PostgreSQL support -RUN apk add --no-cache postgresql postgresql-contrib postgresql-dev libpq - -# MySQL support -RUN apk add --no-cache mariadb-connector-c mariadb-dev mariadb-client - -# Install required python packages -RUN pip install --no-cache-dir -U psycopg2 mysqlclient pgcli mariadb +# Install required base-level python packages +COPY requirements.txt requirements.txt +RUN pip install --no-cache-dir -U -r requirements.txt +# Production code (pulled from tagged github release) FROM base as production + # Clone source code RUN echo "Downloading InvenTree from ${INVENTREE_GIT_REPO}" @@ -88,30 +85,35 @@ RUN git clone --branch ${INVENTREE_GIT_BRANCH} --depth 1 ${INVENTREE_GIT_REPO} $ # Checkout against a particular git tag RUN if [ -n "${INVENTREE_GIT_TAG}" ] ; then cd ${INVENTREE_HOME} && git fetch --all --tags && git checkout tags/${INVENTREE_GIT_TAG} -b v${INVENTREE_GIT_TAG}-branch ; fi +RUN chown -R inventree:inventreegroup ${INVENTREE_HOME}/* + +# Drop to the inventree user +USER inventree + # Install InvenTree packages -RUN pip install --no-cache-dir -U -r ${INVENTREE_HOME}/requirements.txt +RUN pip3 install --no-cache-dir --disable-pip-version-check -r ${INVENTREE_HOME}/requirements.txt -# Copy gunicorn config file -COPY gunicorn.conf.py ${INVENTREE_HOME}/gunicorn.conf.py +# Need to be running from within this directory +WORKDIR ${INVENTREE_MNG_DIR} -# Copy startup scripts -COPY start_prod_server.sh ${INVENTREE_HOME}/start_prod_server.sh -COPY start_prod_worker.sh ${INVENTREE_HOME}/start_prod_worker.sh +# Server init entrypoint +ENTRYPOINT ["/bin/bash", "../docker/init.sh"] -RUN chmod 755 ${INVENTREE_HOME}/start_prod_server.sh -RUN chmod 755 ${INVENTREE_HOME}/start_prod_worker.sh - -WORKDIR ${INVENTREE_HOME} - -# Let us begin -CMD ["bash", "./start_prod_server.sh"] +# Launch the production server +# TODO: Work out why environment variables cannot be interpolated in this command +# TODO: e.g. -b ${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT} fails here +CMD gunicorn -c ./docker/gunicorn.conf.py InvenTree.wsgi -b 0.0.0.0:8000 --chdir ./InvenTree FROM base as dev # The development image requires the source code to be mounted to /home/inventree/ # So from here, we don't actually "do" anything, apart from some file management -ENV INVENTREE_DEV_DIR = "${INVENTREE_HOME}/dev" +ENV INVENTREE_DEV_DIR="${INVENTREE_HOME}/dev" + +# Location for python virtual environment +# If the INVENTREE_PY_ENV variable is set, the entrypoint script will use it! +ENV INVENTREE_PY_ENV="${INVENTREE_DEV_DIR}/env" # Override default path settings ENV INVENTREE_STATIC_ROOT="${INVENTREE_DEV_DIR}/static" @@ -121,5 +123,9 @@ ENV INVENTREE_SECRET_KEY_FILE="${INVENTREE_DEV_DIR}/secret_key.txt" WORKDIR ${INVENTREE_HOME} +# Entrypoint ensures that we are running in the python virtual environment +ENTRYPOINT ["/bin/bash", "./docker/init.sh"] + # Launch the development server -CMD ["bash", "/home/inventree/docker/start_dev_server.sh"] +CMD ["invoke", "server", "-a", "${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT}"] + diff --git a/docker/dev-config.env b/docker/dev-config.env index fe1f073633..927f505649 100644 --- a/docker/dev-config.env +++ b/docker/dev-config.env @@ -1,9 +1,15 @@ +# InvenTree environment variables for a development setup + +# Set DEBUG to False for a production environment! +INVENTREE_DEBUG=True + +# Change verbosity level for debug output +INVENTREE_DEBUG_LEVEL="INFO" + +# Database linking options INVENTREE_DB_ENGINE=sqlite3 INVENTREE_DB_NAME=/home/inventree/dev/inventree_db.sqlite3 -INVENTREE_MEDIA_ROOT=/home/inventree/dev/media -INVENTREE_STATIC_ROOT=/home/inventree/dev/static -INVENTREE_CONFIG_FILE=/home/inventree/dev/config.yaml -INVENTREE_SECRET_KEY_FILE=/home/inventree/dev/secret_key.txt -INVENTREE_DEBUG=true -INVENTREE_WEB_ADDR=0.0.0.0 -INVENTREE_WEB_PORT=8000 \ No newline at end of file +# INVENTREE_DB_HOST=hostaddress +# INVENTREE_DB_PORT=5432 +# INVENTREE_DB_USERNAME=dbuser +# INVENTREE_DB_PASSWEORD=dbpassword diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 29eccc26c6..c4be092189 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -19,6 +19,7 @@ services: context: . target: dev ports: + # Expose web server on port 8000 - 8000:8000 volumes: # Ensure you specify the location of the 'src' directory at the end of this file @@ -26,7 +27,6 @@ services: 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 @@ -35,7 +35,7 @@ services: build: context: . target: dev - entrypoint: /home/inventree/docker/start_dev_worker.sh + command: invoke worker depends_on: - inventree-dev-server volumes: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index dcd35af148..3f8443065a 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -21,12 +21,13 @@ services: # just make sure that you change the INVENTREE_DB_xxx vars below inventree-db: container_name: inventree-db - image: postgres + image: postgres:13 ports: - 5432/tcp environment: - PGDATA=/var/lib/postgresql/data/pgdb # The pguser and pgpassword values must be the same in the other containers + # Ensure that these are correctly configured in your prod-config.env file - POSTGRES_USER=pguser - POSTGRES_PASSWORD=pgpassword volumes: @@ -38,6 +39,8 @@ services: # Uses gunicorn as the web server inventree-server: container_name: inventree-server + # If you wish to specify a particular InvenTree version, do so here + # e.g. image: inventree/inventree:0.5.2 image: inventree/inventree:latest expose: - 8000 @@ -46,39 +49,27 @@ services: volumes: # Data volume must map to /home/inventree/data - data:/home/inventree/data - environment: - # Default environment variables are configured to match the 'db' container - # Note: If you change the database image, these will need to be adjusted - # Note: INVENTREE_DB_HOST should match the container name of the database - - INVENTREE_DB_USER=pguser - - INVENTREE_DB_PASSWORD=pgpassword - - INVENTREE_DB_ENGINE=postgresql - - INVENTREE_DB_NAME=inventree - - INVENTREE_DB_HOST=inventree-db - - INVENTREE_DB_PORT=5432 + env_file: + # Environment variables required for the production server are configured in prod-config.env + - prod-config.env restart: unless-stopped # Background worker process handles long-running or periodic tasks inventree-worker: container_name: inventree-worker + # If you wish to specify a particular InvenTree version, do so here + # e.g. image: inventree/inventree:0.5.2 image: inventree/inventree:latest - entrypoint: ./start_prod_worker.sh + command: invoke worker depends_on: - inventree-db - inventree-server volumes: # Data volume must map to /home/inventree/data - data:/home/inventree/data - environment: - # Default environment variables are configured to match the 'db' container - # Note: If you change the database image, these will need to be adjusted - # Note: INVENTREE_DB_HOST should match the container name of the database - - INVENTREE_DB_USER=pguser - - INVENTREE_DB_PASSWORD=pgpassword - - INVENTREE_DB_ENGINE=postgresql - - INVENTREE_DB_NAME=inventree - - INVENTREE_DB_HOST=inventree-db - - INVENTREE_DB_PORT=5432 + env_file: + # Environment variables required for the production server are configured in prod-config.env + - prod-config.env restart: unless-stopped # nginx acts as a reverse proxy @@ -88,7 +79,7 @@ services: # NOTE: You will need to provide a working nginx.conf file! inventree-proxy: container_name: inventree-proxy - image: nginx + image: nginx:stable depends_on: - inventree-server ports: diff --git a/docker/init.sh b/docker/init.sh new file mode 100644 index 0000000000..b598a3ee79 --- /dev/null +++ b/docker/init.sh @@ -0,0 +1,42 @@ +#!/bin/sh +# exit when any command fails +set -e + +# Create required directory structure (if it does not already exist) +if [[ ! -d "$INVENTREE_STATIC_ROOT" ]]; then + echo "Creating directory $INVENTREE_STATIC_ROOT" + mkdir -p $INVENTREE_STATIC_ROOT +fi + +if [[ ! -d "$INVENTREE_MEDIA_ROOT" ]]; then + echo "Creating directory $INVENTREE_MEDIA_ROOT" + mkdir -p $INVENTREE_MEDIA_ROOT +fi + +# Check if "config.yaml" has been copied into the correct location +if test -f "$INVENTREE_CONFIG_FILE"; then + echo "$INVENTREE_CONFIG_FILE exists - skipping" +else + echo "Copying config file to $INVENTREE_CONFIG_FILE" + cp $INVENTREE_HOME/InvenTree/config_template.yaml $INVENTREE_CONFIG_FILE +fi + +# Setup a python virtual environment +# This should be done on the *mounted* filesystem, +# so that the installed modules persist! +if [[ -n "$INVENTREE_PY_ENV" ]]; then + echo "Using Python virtual environment: ${INVENTREE_PY_ENV}" + # Setup a virtual environment (within the "dev" directory) + python3 -m venv ${INVENTREE_PY_ENV} + + # Activate the virtual environment + source ${INVENTREE_PY_ENV}/bin/activate + + # Note: Python packages will have to be installed on first run + # e.g docker-compose -f docker-compose.dev.yml run inventree-dev-server invoke install +fi + +cd ${INVENTREE_HOME} + +# Launch the CMD *after* the ENTRYPOINT completes +exec "$@" diff --git a/docker/prod-config.env b/docker/prod-config.env new file mode 100644 index 0000000000..50cf7a867b --- /dev/null +++ b/docker/prod-config.env @@ -0,0 +1,16 @@ +# InvenTree environment variables for a production setup + +# Note: If your production setup varies from the example, you may want to change these values + +# Ensure debug is false for a production setup +INVENTREE_DEBUG=False +INVENTREE_LOG_LEVEL="WARNING" + +# Database configuration +# Note: The example setup is for a PostgreSQL database (change as required) +INVENTREE_DB_ENGINE=postgresql +INVENTREE_DB_NAME=inventree +INVENTREE_DB_HOST=inventree-db +INVENTREE_DB_PORT=5432 +INVENTREE_DB_USER=pguser +INVENTREE_DB_PASSWORD=pgpassword diff --git a/docker/requirements.txt b/docker/requirements.txt new file mode 100644 index 0000000000..b15d7c538d --- /dev/null +++ b/docker/requirements.txt @@ -0,0 +1,13 @@ +# Base python requirements for docker containers + +# Basic package requirements +setuptools>=57.4.0 +wheel>=0.37.0 +invoke>=1.4.0 # Invoke build tool +gunicorn>=20.1.0 # Gunicorn web server + +# Database links +psycopg2>=2.9.1 +mysqlclient>=2.0.3 +pgcli>=3.1.0 +mariadb>=1.0.7 diff --git a/docker/start_dev_server.sh b/docker/start_dev_server.sh deleted file mode 100644 index a12a958a9a..0000000000 --- a/docker/start_dev_server.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/bin/sh - -# Create required directory structure (if it does not already exist) -if [[ ! -d "$INVENTREE_STATIC_ROOT" ]]; then - echo "Creating directory $INVENTREE_STATIC_ROOT" - mkdir -p $INVENTREE_STATIC_ROOT -fi - -if [[ ! -d "$INVENTREE_MEDIA_ROOT" ]]; then - echo "Creating directory $INVENTREE_MEDIA_ROOT" - mkdir -p $INVENTREE_MEDIA_ROOT -fi - -# Check if "config.yaml" has been copied into the correct location -if test -f "$INVENTREE_CONFIG_FILE"; then - echo "$INVENTREE_CONFIG_FILE exists - skipping" -else - echo "Copying config file to $INVENTREE_CONFIG_FILE" - cp $INVENTREE_HOME/InvenTree/config_template.yaml $INVENTREE_CONFIG_FILE -fi - -# Setup a virtual environment (within the "dev" directory) -python3 -m venv ./dev/env - -# Activate the virtual environment -source ./dev/env/bin/activate - -echo "Installing required packages..." -pip install --no-cache-dir -U -r ${INVENTREE_HOME}/requirements.txt - -echo "Starting InvenTree server..." - -# Wait for the database to be ready -cd ${INVENTREE_HOME}/InvenTree -python3 manage.py wait_for_db - -sleep 10 - -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 -python3 manage.py check || exit 1 -python3 manage.py migrate --noinput || exit 1 -python3 manage.py migrate --run-syncdb || exit 1 -python3 manage.py clearsessions || exit 1 - -invoke static - -# Launch a development server -python3 manage.py runserver ${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT} diff --git a/docker/start_dev_worker.sh b/docker/start_dev_worker.sh deleted file mode 100644 index 7ee59ff28f..0000000000 --- a/docker/start_dev_worker.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/sh - -echo "Starting InvenTree worker..." - -cd $INVENTREE_HOME - -# Activate virtual environment -source ./dev/env/bin/activate - -sleep 5 - -# Wait for the database to be ready -cd InvenTree -python3 manage.py wait_for_db - -sleep 10 - -# Now we can launch the background worker process -python3 manage.py qcluster diff --git a/docker/start_prod_server.sh b/docker/start_prod_server.sh deleted file mode 100644 index 1660a64e60..0000000000 --- a/docker/start_prod_server.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/bin/sh - -# Create required directory structure (if it does not already exist) -if [[ ! -d "$INVENTREE_STATIC_ROOT" ]]; then - echo "Creating directory $INVENTREE_STATIC_ROOT" - mkdir -p $INVENTREE_STATIC_ROOT -fi - -if [[ ! -d "$INVENTREE_MEDIA_ROOT" ]]; then - echo "Creating directory $INVENTREE_MEDIA_ROOT" - mkdir -p $INVENTREE_MEDIA_ROOT -fi - -# Check if "config.yaml" has been copied into the correct location -if test -f "$INVENTREE_CONFIG_FILE"; then - echo "$INVENTREE_CONFIG_FILE exists - skipping" -else - echo "Copying config file to $INVENTREE_CONFIG_FILE" - cp $INVENTREE_HOME/InvenTree/config_template.yaml $INVENTREE_CONFIG_FILE -fi - -echo "Starting InvenTree server..." - -# Wait for the database to be ready -cd $INVENTREE_MNG_DIR -python3 manage.py wait_for_db - -sleep 10 - -echo "Running InvenTree database migrations and collecting static files..." - -# We assume at this stage that the database is up and running -# Ensure that the database schema are up to date -python3 manage.py check || exit 1 -python3 manage.py migrate --noinput || exit 1 -python3 manage.py migrate --run-syncdb || exit 1 -python3 manage.py prerender || exit 1 -python3 manage.py collectstatic --noinput || exit 1 -python3 manage.py clearsessions || exit 1 - -# Now we can launch the server -gunicorn -c $INVENTREE_HOME/gunicorn.conf.py InvenTree.wsgi -b 0.0.0.0:$INVENTREE_WEB_PORT diff --git a/docker/start_prod_worker.sh b/docker/start_prod_worker.sh deleted file mode 100644 index d0762b430e..0000000000 --- a/docker/start_prod_worker.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/sh - -echo "Starting InvenTree worker..." - -sleep 5 - -# Wait for the database to be ready -cd $INVENTREE_MNG_DIR -python3 manage.py wait_for_db - -sleep 10 - -# Now we can launch the background worker process -python3 manage.py qcluster diff --git a/requirements.txt b/requirements.txt index 637dbda99a..049bedcbeb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,5 @@ -# Basic package requirements -setuptools>=57.4.0 -wheel>=0.37.0 -invoke>=1.4.0 # Invoke build tool -gunicorn>=20.1.0 # Gunicorn web server - # Django framework -Django==3.2.4 # Django package +Django==3.2.4 # Django package pillow==8.2.0 # Image manipulation djangorestframework==3.12.4 # DRF framework diff --git a/tasks.py b/tasks.py index a9168f4649..7ebdd17480 100644 --- a/tasks.py +++ b/tasks.py @@ -65,7 +65,7 @@ def manage(c, cmd, pty=False): cmd - django command to run """ - c.run('cd "{path}" && python3 manage.py {cmd}'.format( + result = c.run('cd "{path}" && python3 manage.py {cmd}'.format( path=managePyDir(), cmd=cmd ), pty=pty) @@ -80,14 +80,6 @@ def install(c): # Install required Python packages with PIP c.run('pip3 install -U -r requirements.txt') - # If a config.yaml file does not exist, copy from the template! - CONFIG_FILE = os.path.join(localDir(), 'InvenTree', 'config.yaml') - CONFIG_TEMPLATE_FILE = os.path.join(localDir(), 'InvenTree', 'config_template.yaml') - - if not os.path.exists(CONFIG_FILE): - print("Config file 'config.yaml' does not exist - copying from template.") - copyfile(CONFIG_TEMPLATE_FILE, CONFIG_FILE) - @task def shell(c): @@ -97,13 +89,6 @@ def shell(c): manage(c, 'shell', pty=True) -@task -def worker(c): - """ - Run the InvenTree background worker process - """ - - manage(c, 'qcluster', pty=True) @task def superuser(c): @@ -113,6 +98,7 @@ def superuser(c): manage(c, 'createsuperuser', pty=True) + @task def check(c): """ @@ -121,13 +107,24 @@ def check(c): manage(c, "check") + @task def wait(c): """ Wait until the database connection is ready """ - manage(c, "wait_for_db") + return manage(c, "wait_for_db") + + +@task(pre=[wait]) +def worker(c): + """ + Run the InvenTree background worker process + """ + + manage(c, 'qcluster', pty=True) + @task def rebuild(c): @@ -137,6 +134,7 @@ def rebuild(c): manage(c, "rebuild_models") + @task def clean_settings(c): """ @@ -145,7 +143,7 @@ def clean_settings(c): manage(c, "clean_settings") -@task +@task(post=[rebuild]) def migrate(c): """ Performs database migrations. @@ -156,7 +154,7 @@ def migrate(c): print("========================================") manage(c, "makemigrations") - manage(c, "migrate") + manage(c, "migrate --noinput") manage(c, "migrate --run-syncdb") manage(c, "check") @@ -175,22 +173,6 @@ def static(c): manage(c, "collectstatic --no-input") -@task(pre=[install, migrate, static, clean_settings]) -def update(c): - """ - Update InvenTree installation. - - This command should be invoked after source code has been updated, - e.g. downloading new code from GitHub. - - The following tasks are performed, in order: - - - install - - migrate - - static - """ - pass - @task(post=[static]) def translate(c): """ @@ -206,7 +188,26 @@ def translate(c): path = os.path.join('InvenTree', 'script', 'translation_stats.py') - c.run(f'python {path}') + c.run(f'python3 {path}') + + +@task(pre=[install, migrate, translate, clean_settings]) +def update(c): + """ + Update InvenTree installation. + + This command should be invoked after source code has been updated, + e.g. downloading new code from GitHub. + + The following tasks are performed, in order: + + - install + - migrate + - translate + - clean_settings + """ + pass + @task def style(c): @@ -217,6 +218,7 @@ def style(c): print("Running PEP style checks...") c.run('flake8 InvenTree') + @task def test(c, database=None): """ @@ -228,6 +230,7 @@ def test(c, database=None): # Run coverage tests manage(c, 'test', pty=True) + @task def coverage(c): """