diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000000..5ebf729c54 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +patreon: inventree +ko_fi: inventree diff --git a/.github/workflows/docker_stable.yaml b/.github/workflows/docker_stable.yaml index 65c31dd9dc..e892b24d13 100644 --- a/.github/workflows/docker_stable.yaml +++ b/.github/workflows/docker_stable.yaml @@ -36,7 +36,7 @@ jobs: push: true target: production build-args: - branch: stable + branch=stable tags: inventree/inventree:stable - name: Image Digest run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/.github/workflows/docker_tag.yaml b/.github/workflows/docker_tag.yaml index 5de27f48be..a9f1c646fc 100644 --- a/.github/workflows/docker_tag.yaml +++ b/.github/workflows/docker_tag.yaml @@ -34,5 +34,5 @@ jobs: push: true target: production build-args: - tag: ${{ github.event.release.tag_name }} + tag=${{ github.event.release.tag_name }} tags: inventree/inventree:${{ github.event.release.tag_name }} diff --git a/InvenTree/InvenTree/api_tester.py b/InvenTree/InvenTree/api_tester.py index 3d8481f84e..fe2057b453 100644 --- a/InvenTree/InvenTree/api_tester.py +++ b/InvenTree/InvenTree/api_tester.py @@ -106,12 +106,12 @@ class InvenTreeAPITestCase(APITestCase): return response - def post(self, url, data, expected_code=None): + def post(self, url, data, expected_code=None, format='json'): """ Issue a POST request """ - response = self.client.post(url, data=data, format='json') + response = self.client.post(url, data=data, format=format) if expected_code is not None: self.assertEqual(response.status_code, expected_code) @@ -130,12 +130,12 @@ class InvenTreeAPITestCase(APITestCase): return response - def patch(self, url, data, files=None, expected_code=None): + def patch(self, url, data, expected_code=None, format='json'): """ Issue a PATCH request """ - response = self.client.patch(url, data=data, files=files, format='json') + response = self.client.patch(url, data=data, format=format) if expected_code is not None: self.assertEqual(response.status_code, expected_code) diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index faef1a6cdb..76b918459c 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -38,7 +38,7 @@ class InvenTreeConfig(AppConfig): try: from django_q.models import Schedule - except (AppRegistryNotReady): + except AppRegistryNotReady: # pragma: no cover return # Remove any existing obsolete tasks @@ -48,7 +48,7 @@ class InvenTreeConfig(AppConfig): try: from django_q.models import Schedule - except (AppRegistryNotReady): + except AppRegistryNotReady: # pragma: no cover return logger.info("Starting background tasks...") @@ -100,10 +100,10 @@ class InvenTreeConfig(AppConfig): try: from djmoney.contrib.exchange.models import ExchangeBackend - from datetime import datetime, timedelta + from InvenTree.tasks import update_exchange_rates from common.settings import currency_code_default - except AppRegistryNotReady: + except AppRegistryNotReady: # pragma: no cover pass base_currency = currency_code_default() @@ -115,23 +115,18 @@ class InvenTreeConfig(AppConfig): last_update = backend.last_update - if last_update is not None: - delta = datetime.now().date() - last_update.date() - if delta > timedelta(days=1): - print(f"Last update was {last_update}") - update = True - else: + if last_update is None: # Never been updated - print("Exchange backend has never been updated") + logger.info("Exchange backend has never been updated") update = True # Backend currency has changed? if not base_currency == backend.base_currency: - print(f"Base currency changed from {backend.base_currency} to {base_currency}") + logger.info(f"Base currency changed from {backend.base_currency} to {base_currency}") update = True except (ExchangeBackend.DoesNotExist): - print("Exchange backend not found - updating") + logger.info("Exchange backend not found - updating") update = True except: @@ -139,4 +134,7 @@ class InvenTreeConfig(AppConfig): return if update: - update_exchange_rates() + try: + update_exchange_rates() + except Exception as e: + logger.error(f"Error updating exchange rates: {e}") diff --git a/InvenTree/InvenTree/ci_render_js.py b/InvenTree/InvenTree/ci_render_js.py index fed1dfb221..e747f1a3c0 100644 --- a/InvenTree/InvenTree/ci_render_js.py +++ b/InvenTree/InvenTree/ci_render_js.py @@ -1,5 +1,6 @@ """ Pull rendered copies of the templated +only used for testing the js files! - This file is omited from coverage """ from django.test import TestCase diff --git a/InvenTree/InvenTree/context.py b/InvenTree/InvenTree/context.py index bd68a0182f..94665f9e07 100644 --- a/InvenTree/InvenTree/context.py +++ b/InvenTree/InvenTree/context.py @@ -23,7 +23,7 @@ def health_status(request): if request.path.endswith('.js'): # Do not provide to script requests - return {} + return {} # pragma: no cover if hasattr(request, '_inventree_health_status'): # Do not duplicate efforts diff --git a/InvenTree/InvenTree/exchange.py b/InvenTree/InvenTree/exchange.py index 4b99953382..a79239568d 100644 --- a/InvenTree/InvenTree/exchange.py +++ b/InvenTree/InvenTree/exchange.py @@ -1,3 +1,7 @@ +import certifi +import ssl +from urllib.request import urlopen + from common.settings import currency_code_default, currency_codes from urllib.error import URLError @@ -24,6 +28,22 @@ class InvenTreeExchange(SimpleExchangeBackend): return { } + def get_response(self, **kwargs): + """ + Custom code to get response from server. + Note: Adds a 5-second timeout + """ + + url = self.get_url(**kwargs) + + try: + context = ssl.create_default_context(cafile=certifi.where()) + response = urlopen(url, timeout=5, context=context) + return response.read() + except: + # Returning None here will raise an error upstream + return None + def update_rates(self, base_currency=currency_code_default()): symbols = ','.join(currency_codes()) diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index 6dad393349..2595f8b5c3 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -475,7 +475,6 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int): continue else: errors.append(_("Invalid group: {g}").format(g=group)) - continue # plus signals either # 1: 'start+': expected number of serials, starting at start @@ -500,7 +499,6 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int): # no case else: errors.append(_("Invalid group: {g}").format(g=group)) - continue # Group should be a number elif group: diff --git a/InvenTree/InvenTree/models.py b/InvenTree/InvenTree/models.py index b42d54cbe9..0fe3136871 100644 --- a/InvenTree/InvenTree/models.py +++ b/InvenTree/InvenTree/models.py @@ -45,6 +45,62 @@ def rename_attachment(instance, filename): return os.path.join(instance.getSubdir(), filename) +class DataImportMixin(object): + """ + Model mixin class which provides support for 'data import' functionality. + + Models which implement this mixin should provide information on the fields available for import + """ + + # Define a map of fields avaialble for import + IMPORT_FIELDS = {} + + @classmethod + def get_import_fields(cls): + """ + Return all available import fields + + Where information on a particular field is not explicitly provided, + introspect the base model to (attempt to) find that information. + + """ + fields = cls.IMPORT_FIELDS + + for name, field in fields.items(): + + # Attempt to extract base field information from the model + base_field = None + + for f in cls._meta.fields: + if f.name == name: + base_field = f + break + + if base_field: + if 'label' not in field: + field['label'] = base_field.verbose_name + + if 'help_text' not in field: + field['help_text'] = base_field.help_text + + fields[name] = field + + return fields + + @classmethod + def get_required_import_fields(cls): + """ Return all *required* import fields """ + fields = {} + + for name, field in cls.get_import_fields().items(): + required = field.get('required', False) + + if required: + fields[name] = field + + return fields + + class ReferenceIndexingMixin(models.Model): """ A mixin for keeping track of numerical copies of the "reference" field. diff --git a/InvenTree/InvenTree/serializers.py b/InvenTree/InvenTree/serializers.py index 59ba0295cb..e21e2fb0fc 100644 --- a/InvenTree/InvenTree/serializers.py +++ b/InvenTree/InvenTree/serializers.py @@ -5,8 +5,8 @@ Serializers used in various InvenTree apps # -*- coding: utf-8 -*- from __future__ import unicode_literals - import os +import tablib from decimal import Decimal @@ -328,4 +328,313 @@ class InvenTreeDecimalField(serializers.FloatField): def to_internal_value(self, data): # Convert the value to a string, and then a decimal - return Decimal(str(data)) + try: + return Decimal(str(data)) + except: + raise serializers.ValidationError(_("Invalid value")) + + +class DataFileUploadSerializer(serializers.Serializer): + """ + Generic serializer for uploading a data file, and extracting a dataset. + + - Validates uploaded file + - Extracts column names + - Extracts data rows + """ + + # Implementing class should register a target model (database model) to be used for import + TARGET_MODEL = None + + class Meta: + fields = [ + 'data_file', + ] + + data_file = serializers.FileField( + label=_("Data File"), + help_text=_("Select data file for upload"), + required=True, + allow_empty_file=False, + ) + + def validate_data_file(self, data_file): + """ + Perform validation checks on the uploaded data file. + """ + + self.filename = data_file.name + + name, ext = os.path.splitext(data_file.name) + + # Remove the leading . from the extension + ext = ext[1:] + + accepted_file_types = [ + 'xls', 'xlsx', + 'csv', 'tsv', + 'xml', + ] + + if ext not in accepted_file_types: + raise serializers.ValidationError(_("Unsupported file type")) + + # Impose a 50MB limit on uploaded BOM files + max_upload_file_size = 50 * 1024 * 1024 + + if data_file.size > max_upload_file_size: + raise serializers.ValidationError(_("File is too large")) + + # Read file data into memory (bytes object) + try: + data = data_file.read() + except Exception as e: + raise serializers.ValidationError(str(e)) + + if ext in ['csv', 'tsv', 'xml']: + try: + data = data.decode() + except Exception as e: + raise serializers.ValidationError(str(e)) + + # Convert to a tablib dataset (we expect headers) + try: + self.dataset = tablib.Dataset().load(data, ext, headers=True) + except Exception as e: + raise serializers.ValidationError(str(e)) + + if len(self.dataset.headers) == 0: + raise serializers.ValidationError(_("No columns found in file")) + + if len(self.dataset) == 0: + raise serializers.ValidationError(_("No data rows found in file")) + + return data_file + + def match_column(self, column_name, field_names, exact=False): + """ + Attempt to match a column name (from the file) to a field (defined in the model) + + Order of matching is: + - Direct match + - Case insensitive match + - Fuzzy match + """ + + column_name = column_name.strip() + + column_name_lower = column_name.lower() + + if column_name in field_names: + return column_name + + for field_name in field_names: + if field_name.lower() == column_name_lower: + return field_name + + if exact: + # Finished available 'exact' matches + return None + + # TODO: Fuzzy pattern matching for column names + + # No matches found + return None + + def extract_data(self): + """ + Returns dataset extracted from the file + """ + + # Provide a dict of available import fields for the model + model_fields = {} + + # Keep track of columns we have already extracted + matched_columns = set() + + if self.TARGET_MODEL: + try: + model_fields = self.TARGET_MODEL.get_import_fields() + except: + pass + + # Extract a list of valid model field names + model_field_names = [key for key in model_fields.keys()] + + # Provide a dict of available columns from the dataset + file_columns = {} + + for header in self.dataset.headers: + column = {} + + # Attempt to "match" file columns to model fields + match = self.match_column(header, model_field_names, exact=True) + + if match is not None and match not in matched_columns: + matched_columns.add(match) + column['value'] = match + else: + column['value'] = None + + file_columns[header] = column + + return { + 'file_fields': file_columns, + 'model_fields': model_fields, + 'rows': [row.values() for row in self.dataset.dict], + 'filename': self.filename, + } + + def save(self): + ... + + +class DataFileExtractSerializer(serializers.Serializer): + """ + Generic serializer for extracting data from an imported dataset. + + - User provides an array of matched headers + - User provides an array of raw data rows + """ + + # Implementing class should register a target model (database model) to be used for import + TARGET_MODEL = None + + class Meta: + fields = [ + 'columns', + 'rows', + ] + + # Mapping of columns + columns = serializers.ListField( + child=serializers.CharField( + allow_blank=True, + ), + ) + + rows = serializers.ListField( + child=serializers.ListField( + child=serializers.CharField( + allow_blank=True, + allow_null=True, + ), + ) + ) + + def validate(self, data): + + data = super().validate(data) + + self.columns = data.get('columns', []) + self.rows = data.get('rows', []) + + if len(self.rows) == 0: + raise serializers.ValidationError(_("No data rows provided")) + + if len(self.columns) == 0: + raise serializers.ValidationError(_("No data columns supplied")) + + self.validate_extracted_columns() + + return data + + @property + def data(self): + + if self.TARGET_MODEL: + try: + model_fields = self.TARGET_MODEL.get_import_fields() + except: + model_fields = {} + + rows = [] + + for row in self.rows: + """ + Optionally pre-process each row, before sending back to the client + """ + + processed_row = self.process_row(self.row_to_dict(row)) + + if processed_row: + rows.append({ + "original": row, + "data": processed_row, + }) + + return { + 'fields': model_fields, + 'columns': self.columns, + 'rows': rows, + } + + def process_row(self, row): + """ + Process a 'row' of data, which is a mapped column:value dict + + Returns either a mapped column:value dict, or None. + + If the function returns None, the column is ignored! + """ + + # Default implementation simply returns the original row data + return row + + def row_to_dict(self, row): + """ + Convert a "row" to a named data dict + """ + + row_dict = { + 'errors': {}, + } + + for idx, value in enumerate(row): + + if idx < len(self.columns): + col = self.columns[idx] + + if col: + row_dict[col] = value + + return row_dict + + def validate_extracted_columns(self): + """ + Perform custom validation of header mapping. + """ + + if self.TARGET_MODEL: + try: + model_fields = self.TARGET_MODEL.get_import_fields() + except: + model_fields = {} + + cols_seen = set() + + for name, field in model_fields.items(): + + required = field.get('required', False) + + # Check for missing required columns + if required: + if name not in self.columns: + raise serializers.ValidationError(_(f"Missing required column: '{name}'")) + + for col in self.columns: + + if not col: + continue + + # Check for duplicated columns + if col in cols_seen: + raise serializers.ValidationError(_(f"Duplicate column: '{col}'")) + + cols_seen.add(col) + + def save(self): + """ + No "save" action for this serializer + """ + ... diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 171426bce5..c45be06f4b 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -82,7 +82,7 @@ logging.basicConfig( ) if log_level not in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']: - log_level = 'WARNING' + log_level = 'WARNING' # pragma: no cover LOGGING = { 'version': 1, @@ -119,20 +119,20 @@ d) Create "secret_key.txt" if it does not exist if os.getenv("INVENTREE_SECRET_KEY"): # Secret key passed in directly - SECRET_KEY = os.getenv("INVENTREE_SECRET_KEY").strip() - logger.info("SECRET_KEY loaded by INVENTREE_SECRET_KEY") + SECRET_KEY = os.getenv("INVENTREE_SECRET_KEY").strip() # pragma: no cover + logger.info("SECRET_KEY loaded by INVENTREE_SECRET_KEY") # pragma: no cover else: # Secret key passed in by file location key_file = os.getenv("INVENTREE_SECRET_KEY_FILE") if key_file: - key_file = os.path.abspath(key_file) + key_file = os.path.abspath(key_file) # pragma: no cover else: # default secret key location key_file = os.path.join(BASE_DIR, "secret_key.txt") key_file = os.path.abspath(key_file) - if not os.path.exists(key_file): + if not os.path.exists(key_file): # pragma: no cover logger.info(f"Generating random key file at '{key_file}'") # Create a random key file with open(key_file, 'w') as f: @@ -144,7 +144,7 @@ else: try: SECRET_KEY = open(key_file, "r").read().strip() - except Exception: + except Exception: # pragma: no cover logger.exception(f"Couldn't load keyfile {key_file}") sys.exit(-1) @@ -156,7 +156,7 @@ STATIC_ROOT = os.path.abspath( ) ) -if STATIC_ROOT is None: +if STATIC_ROOT is None: # pragma: no cover print("ERROR: INVENTREE_STATIC_ROOT directory not defined") sys.exit(1) @@ -168,7 +168,7 @@ MEDIA_ROOT = os.path.abspath( ) ) -if MEDIA_ROOT is None: +if MEDIA_ROOT is None: # pragma: no cover print("ERROR: INVENTREE_MEDIA_ROOT directory is not defined") sys.exit(1) @@ -187,7 +187,7 @@ if cors_opt: CORS_ORIGIN_ALLOW_ALL = cors_opt.get('allow_all', False) if not CORS_ORIGIN_ALLOW_ALL: - CORS_ORIGIN_WHITELIST = cors_opt.get('whitelist', []) + CORS_ORIGIN_WHITELIST = cors_opt.get('whitelist', []) # pragma: no cover # Web URL endpoint for served static files STATIC_URL = '/static/' @@ -215,7 +215,7 @@ if DEBUG: logger.info("InvenTree running with DEBUG enabled") if DEMO_MODE: - logger.warning("InvenTree running in DEMO mode") + logger.warning("InvenTree running in DEMO mode") # pragma: no cover logger.debug(f"MEDIA_ROOT: '{MEDIA_ROOT}'") logger.debug(f"STATIC_ROOT: '{STATIC_ROOT}'") @@ -304,7 +304,7 @@ AUTHENTICATION_BACKENDS = CONFIG.get('authentication_backends', [ ]) # If the debug toolbar is enabled, add the modules -if DEBUG and CONFIG.get('debug_toolbar', False): +if DEBUG and CONFIG.get('debug_toolbar', False): # pragma: no cover logger.info("Running with DEBUG_TOOLBAR enabled") INSTALLED_APPS.append('debug_toolbar') MIDDLEWARE.append('debug_toolbar.middleware.DebugToolbarMiddleware') @@ -396,7 +396,7 @@ for key in db_keys: reqiured_keys = ['ENGINE', 'NAME'] for key in reqiured_keys: - if key not in db_config: + if key not in db_config: # pragma: no cover error_msg = f'Missing required database configuration value {key}' logger.error(error_msg) @@ -415,7 +415,7 @@ db_engine = db_config['ENGINE'].lower() # Correct common misspelling if db_engine == 'sqlite': - db_engine = 'sqlite3' + db_engine = 'sqlite3' # pragma: no cover if db_engine in ['sqlite3', 'postgresql', 'mysql']: # Prepend the required python module string @@ -443,7 +443,7 @@ Ref: https://docs.djangoproject.com/en/3.2/ref/settings/#std:setting-OPTIONS db_options = db_config.get("OPTIONS", db_config.get("options", {})) # Specific options for postgres backend -if "postgres" in db_engine: +if "postgres" in db_engine: # pragma: no cover from psycopg2.extensions import ( ISOLATION_LEVEL_READ_COMMITTED, ISOLATION_LEVEL_SERIALIZABLE, @@ -505,7 +505,7 @@ if "postgres" in db_engine: ) # Specific options for MySql / MariaDB backend -if "mysql" in db_engine: +if "mysql" in db_engine: # pragma: no cover # TODO TCP time outs and keepalives # MariaDB's default isolation level is Repeatable Read which is @@ -546,7 +546,7 @@ _cache_port = _cache_config.get( "port", os.getenv("INVENTREE_CACHE_PORT", "6379") ) -if _cache_host: +if _cache_host: # pragma: no cover # We are going to rely upon a possibly non-localhost for our cache, # so don't wait too long for the cache as nothing in the cache should be # irreplacable. @@ -591,7 +591,7 @@ else: try: # 4 background workers seems like a sensible default background_workers = int(os.environ.get('INVENTREE_BACKGROUND_WORKERS', 4)) -except ValueError: +except ValueError: # pragma: no cover background_workers = 4 # django-q configuration @@ -606,7 +606,7 @@ Q_CLUSTER = { 'sync': False, } -if _cache_host: +if _cache_host: # pragma: no cover # If using external redis cache, make the cache the broker for Django Q # as well Q_CLUSTER["django_redis"] = "worker" @@ -641,7 +641,7 @@ AUTH_PASSWORD_VALIDATORS = [ EXTRA_URL_SCHEMES = CONFIG.get('extra_url_schemes', []) -if not type(EXTRA_URL_SCHEMES) in [list]: +if not type(EXTRA_URL_SCHEMES) in [list]: # pragma: no cover logger.warning("extra_url_schemes not correctly formatted") EXTRA_URL_SCHEMES = [] @@ -659,6 +659,7 @@ LANGUAGES = [ ('es-mx', _('Spanish (Mexican)')), ('fr', _('French')), ('he', _('Hebrew')), + ('hu', _('Hungarian')), ('it', _('Italian')), ('ja', _('Japanese')), ('ko', _('Korean')), @@ -675,7 +676,7 @@ LANGUAGES = [ ] # Testing interface translations -if get_setting('TEST_TRANSLATIONS', False): +if get_setting('TEST_TRANSLATIONS', False): # pragma: no cover # Set default language LANGUAGE_CODE = 'xx' @@ -703,7 +704,7 @@ CURRENCIES = CONFIG.get( # Check that each provided currency is supported for currency in CURRENCIES: - if currency not in moneyed.CURRENCIES: + if currency not in moneyed.CURRENCIES: # pragma: no cover print(f"Currency code '{currency}' is not supported") sys.exit(1) @@ -777,7 +778,7 @@ USE_L10N = True # Do not use native timezone support in "test" mode # It generates a *lot* of cruft in the logs if not TESTING: - USE_TZ = True + USE_TZ = True # pragma: no cover DATE_INPUT_FORMATS = [ "%Y-%m-%d", @@ -805,7 +806,7 @@ SITE_ID = 1 # Load the allauth social backends SOCIAL_BACKENDS = CONFIG.get('social_backends', []) for app in SOCIAL_BACKENDS: - INSTALLED_APPS.append(app) + INSTALLED_APPS.append(app) # pragma: no cover SOCIALACCOUNT_PROVIDERS = CONFIG.get('social_providers', []) @@ -879,7 +880,7 @@ PLUGIN_DIRS = ['plugin.builtin', 'barcodes.plugins', ] if not TESTING: # load local deploy directory in prod - PLUGIN_DIRS.append('plugins') + PLUGIN_DIRS.append('plugins') # pragma: no cover if DEBUG or TESTING: # load samples in debug mode diff --git a/InvenTree/InvenTree/status.py b/InvenTree/InvenTree/status.py index 512c68e93b..cc9df701c6 100644 --- a/InvenTree/InvenTree/status.py +++ b/InvenTree/InvenTree/status.py @@ -60,21 +60,21 @@ def is_email_configured(): configured = False # Display warning unless in test mode - if not settings.TESTING: + if not settings.TESTING: # pragma: no cover logger.debug("EMAIL_HOST is not configured") if not settings.EMAIL_HOST_USER: configured = False # Display warning unless in test mode - if not settings.TESTING: + if not settings.TESTING: # pragma: no cover logger.debug("EMAIL_HOST_USER is not configured") if not settings.EMAIL_HOST_PASSWORD: configured = False # Display warning unless in test mode - if not settings.TESTING: + if not settings.TESTING: # pragma: no cover logger.debug("EMAIL_HOST_PASSWORD is not configured") return configured @@ -89,15 +89,15 @@ def check_system_health(**kwargs): result = True - if not is_worker_running(**kwargs): + if not is_worker_running(**kwargs): # pragma: no cover result = False logger.warning(_("Background worker check failed")) - if not is_email_configured(): + if not is_email_configured(): # pragma: no cover result = False logger.warning(_("Email backend not configured")) - if not result: + if not result: # pragma: no cover logger.warning(_("InvenTree system health checks failed")) return result diff --git a/InvenTree/InvenTree/status_codes.py b/InvenTree/InvenTree/status_codes.py index c8917d679b..ffe22039c9 100644 --- a/InvenTree/InvenTree/status_codes.py +++ b/InvenTree/InvenTree/status_codes.py @@ -258,6 +258,7 @@ class StockHistoryCode(StatusCode): # Build order codes BUILD_OUTPUT_CREATED = 50 BUILD_OUTPUT_COMPLETED = 55 + BUILD_CONSUMED = 57 # Sales order codes @@ -298,6 +299,7 @@ class StockHistoryCode(StatusCode): BUILD_OUTPUT_CREATED: _('Build order output created'), BUILD_OUTPUT_COMPLETED: _('Build order output completed'), + BUILD_CONSUMED: _('Consumed by build order'), RECEIVED_AGAINST_PURCHASE_ORDER: _('Received against purchase order') diff --git a/InvenTree/InvenTree/tasks.py b/InvenTree/InvenTree/tasks.py index 0a098e5f8c..9d4039d4b1 100644 --- a/InvenTree/InvenTree/tasks.py +++ b/InvenTree/InvenTree/tasks.py @@ -28,7 +28,7 @@ def schedule_task(taskname, **kwargs): try: from django_q.models import Schedule - except (AppRegistryNotReady): + except AppRegistryNotReady: # pragma: no cover logger.info("Could not start background tasks - App registry not ready") return @@ -47,7 +47,7 @@ def schedule_task(taskname, **kwargs): func=taskname, **kwargs ) - except (OperationalError, ProgrammingError): + except (OperationalError, ProgrammingError): # pragma: no cover # Required if the DB is not ready yet pass @@ -108,10 +108,10 @@ def offload_task(taskname, *args, force_sync=False, **kwargs): # Workers are not running: run it as synchronous task _func(*args, **kwargs) - except (AppRegistryNotReady): + except AppRegistryNotReady: # pragma: no cover logger.warning(f"Could not offload task '{taskname}' - app registry not ready") return - except (OperationalError, ProgrammingError): + except (OperationalError, ProgrammingError): # pragma: no cover logger.warning(f"Could not offload task '{taskname}' - database not ready") @@ -127,7 +127,7 @@ def heartbeat(): try: from django_q.models import Success logger.info("Could not perform heartbeat task - App registry not ready") - except AppRegistryNotReady: + except AppRegistryNotReady: # pragma: no cover return threshold = timezone.now() - timedelta(minutes=30) @@ -150,7 +150,7 @@ def delete_successful_tasks(): try: from django_q.models import Success - except AppRegistryNotReady: + except AppRegistryNotReady: # pragma: no cover logger.info("Could not perform 'delete_successful_tasks' - App registry not ready") return @@ -184,7 +184,7 @@ def delete_old_error_logs(): logger.info(f"Deleting {errors.count()} old error logs") errors.delete() - except AppRegistryNotReady: + except AppRegistryNotReady: # pragma: no cover # Apps not yet loaded logger.info("Could not perform 'delete_old_error_logs' - App registry not ready") return @@ -197,7 +197,7 @@ def check_for_updates(): try: import common.models - except AppRegistryNotReady: + except AppRegistryNotReady: # pragma: no cover # Apps not yet loaded! logger.info("Could not perform 'check_for_updates' - App registry not ready") return @@ -244,7 +244,7 @@ def update_exchange_rates(): from InvenTree.exchange import InvenTreeExchange from djmoney.contrib.exchange.models import ExchangeBackend, Rate from common.settings import currency_code_default, currency_codes - except AppRegistryNotReady: + except AppRegistryNotReady: # pragma: no cover # Apps not yet loaded! logger.info("Could not perform 'update_exchange_rates' - App registry not ready") return @@ -269,10 +269,13 @@ def update_exchange_rates(): logger.info(f"Using base currency '{base}'") - backend.update_rates(base_currency=base) + try: + backend.update_rates(base_currency=base) - # Remove any exchange rates which are not in the provided currencies - Rate.objects.filter(backend="InvenTreeExchange").exclude(currency__in=currency_codes()).delete() + # Remove any exchange rates which are not in the provided currencies + Rate.objects.filter(backend="InvenTreeExchange").exclude(currency__in=currency_codes()).delete() + except Exception as e: + logger.error(f"Error updating exchange rates: {e}") def send_email(subject, body, recipients, from_email=None, html_message=None): diff --git a/InvenTree/InvenTree/test_urls.py b/InvenTree/InvenTree/test_urls.py index 457358ce2b..8b55d4e042 100644 --- a/InvenTree/InvenTree/test_urls.py +++ b/InvenTree/InvenTree/test_urls.py @@ -92,7 +92,7 @@ class URLTest(TestCase): result[0].strip(), result[1].strip() ]) - elif len(result) == 1: + elif len(result) == 1: # pragma: no cover urls.append([ result[0].strip(), '' diff --git a/InvenTree/InvenTree/test_views.py b/InvenTree/InvenTree/test_views.py index 4f7dddfde4..f6e6de56ff 100644 --- a/InvenTree/InvenTree/test_views.py +++ b/InvenTree/InvenTree/test_views.py @@ -1,11 +1,14 @@ -""" Unit tests for the main web views """ +""" +Unit tests for the main web views +""" + +import re +import os from django.test import TestCase from django.urls import reverse from django.contrib.auth import get_user_model -import os - class ViewTests(TestCase): """ Tests for various top-level views """ @@ -16,9 +19,13 @@ class ViewTests(TestCase): def setUp(self): # Create a user - get_user_model().objects.create_user(self.username, 'user@email.com', self.password) + self.user = get_user_model().objects.create_user(self.username, 'user@email.com', self.password) + self.user.set_password(self.password) + self.user.save() - self.client.login(username=self.username, password=self.password) + result = self.client.login(username=self.username, password=self.password) + + self.assertEqual(result, True) def test_api_doc(self): """ Test that the api-doc view works """ @@ -27,3 +34,51 @@ class ViewTests(TestCase): response = self.client.get(api_url) self.assertEqual(response.status_code, 200) + + def test_index_redirect(self): + """ + top-level URL should redirect to "index" page + """ + + response = self.client.get("/") + + self.assertEqual(response.status_code, 302) + + def get_index_page(self): + """ + Retrieve the index page (used for subsequent unit tests) + """ + + response = self.client.get("/index/") + + self.assertEqual(response.status_code, 200) + + return str(response.content.decode()) + + def test_panels(self): + """ + Test that the required 'panels' are present + """ + + content = self.get_index_page() + + self.assertIn("
", content) + + # TODO: In future, run the javascript and ensure that the panels get created! + + def test_js_load(self): + """ + Test that the required javascript files are loaded correctly + """ + + # Change this number as more javascript files are added to the index page + N_SCRIPT_FILES = 35 + + content = self.get_index_page() + + # Extract all required javascript files from the index page content + script_files = re.findall("