diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 40437b413dd0..df945dc432d2 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -73,7 +73,7 @@ jobs: python-version: ${{ env.python_version }} - name: Version Check run: | - pip install --require-hashes -r .github/requirements.txt + pip install --require-hashes -r contrib/dev_reqs/requirements.txt python3 .github/scripts/version_check.py echo "git_commit_hash=$(git rev-parse --short HEAD)" >> $GITHUB_ENV echo "git_commit_date=$(git show -s --format=%ci)" >> $GITHUB_ENV diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index a6efc32d2b76..e1ed0a7a0aa1 100644 --- a/.github/workflows/qc_checks.yaml +++ b/.github/workflows/qc_checks.yaml @@ -104,7 +104,7 @@ jobs: uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # pin@v3.0.1 - name: Check Version run: | - pip install --require-hashes -r .github/requirements.txt + pip install --require-hashes -r contrib/dev_reqs/requirements.txt python3 .github/scripts/version_check.py mkdocs: @@ -122,7 +122,7 @@ jobs: python-version: ${{ env.python_version }} - name: Check Config run: | - pip install --require-hashes -r .github/requirements.txt + pip install --require-hashes -r contrib/dev_reqs/requirements.txt pip install --require-hashes -r docs/requirements.txt python docs/ci/check_mkdocs_config.py - name: Check Links @@ -168,7 +168,7 @@ jobs: - name: Download public schema if: needs.paths-filter.outputs.api == 'false' run: | - pip install --require-hashes -r .github/requirements.txt >/dev/null 2>&1 + pip install --require-hashes -r contrib/dev_reqs/requirements.txt >/dev/null 2>&1 version="$(python3 .github/scripts/version_check.py only_version 2>&1)" echo "Version: $version" url="https://raw.githubusercontent.com/inventree/schema/main/export/${version}/api.yaml" @@ -187,7 +187,7 @@ jobs: id: version if: github.ref == 'refs/heads/master' && needs.paths-filter.outputs.api == 'true' run: | - pip install --require-hashes -r .github/requirements.txt >/dev/null 2>&1 + pip install --require-hashes -r contrib/dev_reqs/requirements.txt >/dev/null 2>&1 version="$(python3 .github/scripts/version_check.py only_version 2>&1)" echo "Version: $version" echo "version=$version" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index bc63b1a71845..f93c99a1ec5e 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -18,7 +18,7 @@ jobs: uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # pin@v4.1.6 - name: Version Check run: | - pip install --require-hashes -r .github/requirements.txt + pip install --require-hashes -r contrib/dev_reqs/requirements.txt python3 .github/scripts/version_check.py - name: Push to Stable Branch uses: ad-m/github-push-action@d91a481090679876dfc4178fef17f286781251df # pin@v0.8.0 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6cace6d1476c..6c99a6f6d591 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -39,8 +39,8 @@ repos: files: src/backend/requirements\.(in|txt)$ - id: pip-compile name: pip-compile requirements.txt - args: [.github/requirements.in, -o, .github/requirements.txt,--python-version=3.9, --no-strip-extras, --generate-hashes] - files: .github/requirements\.(in|txt)$ + args: [contrib/dev_reqs/requirements.in, -o, contrib/dev_reqs/requirements.txt,--python-version=3.9, --no-strip-extras, --generate-hashes] + files: contrib/dev_reqs/requirements\.(in|txt)$ - id: pip-compile name: pip-compile requirements.txt args: [docs/requirements.in, -o, docs/requirements.txt,--python-version=3.9, --no-strip-extras, --generate-hashes] diff --git a/.github/requirements.in b/contrib/dev_reqs/requirements.in similarity index 100% rename from .github/requirements.in rename to contrib/dev_reqs/requirements.in diff --git a/.github/requirements.txt b/contrib/dev_reqs/requirements.txt similarity index 99% rename from .github/requirements.txt rename to contrib/dev_reqs/requirements.txt index 23393a4a952e..c4099a325e7d 100644 --- a/.github/requirements.txt +++ b/contrib/dev_reqs/requirements.txt @@ -1,5 +1,5 @@ # This file was autogenerated by uv via the following command: -# uv pip compile .github/requirements.in -o .github/requirements.txt --python-version=3.9 --no-strip-extras --generate-hashes +# uv pip compile contrib/dev_reqs/requirements.in -o contrib/dev_reqs/requirements.txt --python-version=3.9 --no-strip-extras --generate-hashes certifi==2024.2.2 \ --hash=sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f \ --hash=sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1 diff --git a/contrib/packager.io/functions.sh b/contrib/packager.io/functions.sh index 8f7f47cc66b3..bb236be77d1e 100755 --- a/contrib/packager.io/functions.sh +++ b/contrib/packager.io/functions.sh @@ -44,6 +44,18 @@ function detect_ip() { echo "IP address is ${INVENTREE_IP}" } +function detect_python() { + # Detect if there is already a python version installed in /opt/inventree/env/lib + if test -f "${APP_HOME}/env/bin/python"; then + echo "# Python environment already present" + # Extract earliest python version initialised from /opt/inventree/env/lib + SETUP_PYTHON=$(ls -1 ${APP_HOME}/env/bin/python* | sort | head -n 1) + echo "# Found earliest version: ${SETUP_PYTHON}" + else + echo "# No python environment found - using ${SETUP_PYTHON}" + fi +} + function get_env() { envname=$1 @@ -90,7 +102,7 @@ function detect_envs() { echo "# Using existing config file: ${INVENTREE_CONFIG_FILE}" # Install parser - pip install --require-hashes -r ${APP_HOME}/.github/requirements.txt -q + pip install --require-hashes -r ${APP_HOME}/contrib/dev_reqs/requirements.txt -q # Load config local CONF=$(cat ${INVENTREE_CONFIG_FILE} | jc --yaml) @@ -163,12 +175,20 @@ function create_initscripts() { sudo -u ${APP_USER} --preserve-env=$SETUP_ENVS bash -c "cd ${APP_HOME} && ${SETUP_PYTHON} -m venv env" sudo -u ${APP_USER} --preserve-env=$SETUP_ENVS bash -c "cd ${APP_HOME} && env/bin/pip install invoke wheel" + # Check INSTALLER_EXTRA exists and load it + if test -f "${APP_HOME}/INSTALLER_EXTRA"; then + echo "# Loading extra packages from INSTALLER_EXTRA" + source ${APP_HOME}/INSTALLER_EXTRA + fi + if [ -n "${SETUP_EXTRA_PIP}" ]; then echo "# Installing extra pip packages" if [ -n "${SETUP_DEBUG}" ]; then echo "# Extra pip packages: ${SETUP_EXTRA_PIP}" fi sudo -u ${APP_USER} --preserve-env=$SETUP_ENVS bash -c "cd ${APP_HOME} && env/bin/pip install ${SETUP_EXTRA_PIP}" + # Write extra packages to INSTALLER_EXTRA + echo "SETUP_EXTRA_PIP='${SETUP_EXTRA_PIP}'" >>${APP_HOME}/INSTALLER_EXTRA fi fi @@ -283,6 +303,20 @@ function set_env() { chown ${APP_USER}:${APP_GROUP} ${DATA_DIR} ${INVENTREE_CONFIG_FILE} } +function set_site() { + # Ensure IP is known + if [ -z "${INVENTREE_IP}" ]; then + echo "# No IP address found - skipping" + return + fi + + # Check if INVENTREE_SITE_URL in inventree config + if [ -z "$(inventree config:get INVENTREE_SITE_URL)" ]; then + echo "# Setting up InvenTree site URL" + inventree config:set INVENTREE_SITE_URL=http://${INVENTREE_IP} + fi +} + function final_message() { echo -e "####################################################################################" echo -e "This InvenTree install uses nginx, the settings for the webserver can be found in" diff --git a/contrib/packager.io/postinstall.sh b/contrib/packager.io/postinstall.sh index 03230b955a84..89c88833b100 100755 --- a/contrib/packager.io/postinstall.sh +++ b/contrib/packager.io/postinstall.sh @@ -33,6 +33,7 @@ detect_envs detect_docker detect_initcmd detect_ip +detect_python # create processes create_initscripts @@ -45,6 +46,7 @@ update_or_install if [ "${SETUP_CONF_LOADED}" = "true" ]; then set_env fi +set_site start_inventree # show info diff --git a/docs/docs/report/context_variables.md b/docs/docs/report/context_variables.md index 10180a05047b..e6fe9bb69845 100644 --- a/docs/docs/report/context_variables.md +++ b/docs/docs/report/context_variables.md @@ -16,7 +16,6 @@ In addition to the model-specific context variables, the following global contex | base_url | The base URL for the InvenTree instance | | date | Current date, represented as a Python datetime.date object | | datetime | Current datetime, represented as a Python datetime object | -| request | The Django request object associated with the printing process | | template | The report template instance which is being rendered against | | template_description | Description of the report template | | template_name | Name of the report template | diff --git a/docs/docs/start/config.md b/docs/docs/start/config.md index 962fc8879836..9023fde60cb5 100644 --- a/docs/docs/start/config.md +++ b/docs/docs/start/config.md @@ -22,7 +22,11 @@ The InvenTree server tries to locate the `config.yaml` configuration file on sta !!! tip "Config File Location" When the InvenTree server boots, it will report the location where it expects to find the configuration file -The configuration file *template* can be found on [GitHub]({{ sourcefile("src/backend/InvenTree/config_template.yaml") }}) +#### Configuration File Template + +The configuration file *template* can be found on [GitHub]({{ sourcefile("src/backend/InvenTree/config_template.yaml") }}), and is shown below: + +{{ includefile("src/backend/InvenTree/config_template.yaml", "Configuration File Template", format="yaml") }} !!! info "Template File" The default configuration file (as defined by the template linked above) will be copied to the specified configuration file location on first run, if a configuration file is not found in that location. diff --git a/docs/main.py b/docs/main.py index d6e85bc85d14..78a43393157d 100644 --- a/docs/main.py +++ b/docs/main.py @@ -167,27 +167,37 @@ def listimages(subdir): return assets @env.macro - def templatefile(filename): - """Include code for a provided template file.""" - here = os.path.dirname(__file__) - template_dir = os.path.join( - here, '..', 'src', 'backend', 'InvenTree', 'report', 'templates' - ) - template_file = os.path.join(template_dir, filename) - template_file = os.path.abspath(template_file) + def includefile(filename: str, title: str, format: str = ''): + """Include a file in the documentation, in a 'collapse' block. - basename = os.path.basename(filename) + Arguments: + - filename: The name of the file to include (relative to the top-level directory) + - title: + """ + here = os.path.dirname(__file__) + path = os.path.join(here, '..', filename) + path = os.path.abspath(path) - if not os.path.exists(template_file): - raise FileNotFoundError(f'Report template file {filename} does not exist.') + if not os.path.exists(path): + raise FileNotFoundError(f'Required file {path} does not exist.') - with open(template_file, 'r') as f: + with open(path, 'r') as f: content = f.read() - data = f'??? abstract "Template: {basename}"\n\n' - data += ' ```html\n' + data = f'??? abstract "{title}"\n\n' + data += f' ```{format}\n' data += textwrap.indent(content, ' ') data += '\n\n' data += ' ```\n\n' return data + + @env.macro + def templatefile(filename): + """Include code for a provided template file.""" + base = os.path.basename(filename) + fn = os.path.join( + 'src', 'backend', 'InvenTree', 'report', 'templates', filename + ) + + return includefile(fn, f'Template: {base}', format='html') diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 0ad025b8a7df..195b1631c3c8 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,12 +1,15 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 201 +INVENTREE_API_VERSION = 202 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v202 - 2024-05-27 : https://github.com/inventree/InvenTree/pull/7343 + - Adjust "required" attribute of Part.category field to be optional + v201 - 2024-05-21 : https://github.com/inventree/InvenTree/pull/7074 - Major refactor of the report template / report printing interface - This is a *breaking change* to the report template API diff --git a/src/backend/InvenTree/InvenTree/metadata.py b/src/backend/InvenTree/InvenTree/metadata.py index 242516f59b4b..19404f46b274 100644 --- a/src/backend/InvenTree/InvenTree/metadata.py +++ b/src/backend/InvenTree/InvenTree/metadata.py @@ -115,6 +115,31 @@ def determine_metadata(self, request, view): return metadata + def override_value(self, field_name, field_value, model_value): + """Override a value on the serializer with a matching value for the model. + + This is used to override the serializer values with model values, + if (and *only* if) the model value should take precedence. + + The values are overridden under the following conditions: + - field_value is None + - model_value is callable, and field_value is not (this indicates that the model value is translated) + - model_value is not a string, and field_value is a string (this indicates that the model value is translated) + """ + if model_value and not field_value: + return model_value + + if field_value and not model_value: + return field_value + + if callable(model_value) and not callable(field_value): + return model_value + + if type(model_value) is not str and type(field_value) is str: + return model_value + + return field_value + def get_serializer_info(self, serializer): """Override get_serializer_info so that we can add 'default' values to any fields whose Meta.model specifies a default value.""" self.serializer = serializer @@ -122,19 +147,25 @@ def get_serializer_info(self, serializer): serializer_info = super().get_serializer_info(serializer) # Look for any dynamic fields which were not available when the serializer was instantiated - for field_name in serializer.Meta.fields: - if field_name in serializer_info: - # Already know about this one - continue + if hasattr(serializer, 'Meta'): + for field_name in serializer.Meta.fields: + if field_name in serializer_info: + # Already know about this one + continue - if hasattr(serializer, field_name): - field = getattr(serializer, field_name) - serializer_info[field_name] = self.get_field_info(field) + if hasattr(serializer, field_name): + field = getattr(serializer, field_name) + serializer_info[field_name] = self.get_field_info(field) model_class = None # Attributes to copy extra attributes from the model to the field (if they don't exist) - extra_attributes = ['help_text', 'max_length'] + # Note that the attributes may be named differently on the underlying model! + extra_attributes = { + 'help_text': 'help_text', + 'max_length': 'max_length', + 'label': 'verbose_name', + } try: model_class = serializer.Meta.model @@ -165,10 +196,12 @@ def get_serializer_info(self, serializer): elif name in model_default_values: serializer_info[name]['default'] = model_default_values[name] - for attr in extra_attributes: - if attr not in serializer_info[name]: - if hasattr(field, attr): - serializer_info[name][attr] = getattr(field, attr) + for field_key, model_key in extra_attributes.items(): + field_value = serializer_info[name].get(field_key, None) + model_value = getattr(field, model_key, None) + + if value := self.override_value(name, field_value, model_value): + serializer_info[name][field_key] = value # Iterate through relations for name, relation in model_fields.relations.items(): @@ -186,13 +219,12 @@ def get_serializer_info(self, serializer): relation.model_field.get_limit_choices_to() ) - for attr in extra_attributes: - if attr not in serializer_info[name] and hasattr( - relation.model_field, attr - ): - serializer_info[name][attr] = getattr( - relation.model_field, attr - ) + for field_key, model_key in extra_attributes.items(): + field_value = serializer_info[name].get(field_key, None) + model_value = getattr(relation.model_field, model_key, None) + + if value := self.override_value(name, field_value, model_value): + serializer_info[name][field_key] = value if name in model_default_values: serializer_info[name]['default'] = model_default_values[name] diff --git a/src/backend/InvenTree/config_template.yaml b/src/backend/InvenTree/config_template.yaml index b9bb21222035..272dd4144f8e 100644 --- a/src/backend/InvenTree/config_template.yaml +++ b/src/backend/InvenTree/config_template.yaml @@ -1,3 +1,6 @@ +# InvenTree Configuration Template +# Ref: https://docs.inventree.org/en/stable/start/config/ +# Note: Environment variables take precedence over values set in this file # Secret key for backend # Use the environment variable INVENTREE_SECRET_KEY_FILE @@ -5,16 +8,10 @@ # Database backend selection - Configure backend database settings # Documentation: https://docs.inventree.org/en/latest/start/config/ - # Note: Database configuration options can also be specified from environmental variables, # with the prefix INVENTREE_DB_ # e.g INVENTREE_DB_NAME / INVENTREE_DB_USER / INVENTREE_DB_PASSWORD database: - # Uncomment (and edit) one of the database configurations below, - # or specify database options using environment variables - - # Refer to the django documentation for full list of options - # --- Available options: --- # ENGINE: Database engine. Selection from: # - mysql @@ -26,65 +23,29 @@ database: # HOST: Database host address (if required) # PORT: Database host port (if required) - # --- Database settings --- - #ENGINE: sampleengine - #NAME: '/path/to/database' - #USER: sampleuser - #PASSWORD: samplepassword - #HOST: samplehost - #PORT: 123456 - - # --- Example Configuration - MySQL --- - #ENGINE: mysql - #NAME: inventree - #USER: inventree - #PASSWORD: inventree_password - #HOST: 'localhost' - #PORT: '3306' - - # --- Example Configuration - Postgresql --- - #ENGINE: postgresql - #NAME: inventree - #USER: inventree - #PASSWORD: inventree_password - #HOST: 'localhost' - #PORT: '5432' - - # --- Example Configuration - sqlite3 --- - # ENGINE: sqlite3 - # NAME: '/home/inventree/database.sqlite3' - -# Set debug to False to run in production mode -# Use the environment variable INVENTREE_DEBUG +# Set debug to False to run in production mode, or use the environment variable INVENTREE_DEBUG debug: True -# Set to False to disable the admin interface (default = True) -# Or, use the environment variable INVENTREE_ADMIN_ENABLED +# Set to False to disable the admin interfac, or use the environment variable INVENTREE_ADMIN_ENABLED #admin_enabled: True -# Set the admin URL (default is 'admin') -# Or, use the environment variable INVENTREE_ADMIN_URL +# Set the admin URL, or use the environment variable INVENTREE_ADMIN_URL #admin_url: 'admin' -# Configure the system logging level -# Use environment variable INVENTREE_LOG_LEVEL +# Configure the system logging level (or use environment variable INVENTREE_LOG_LEVEL) # Options: DEBUG / INFO / WARNING / ERROR / CRITICAL log_level: WARNING -# Enable database-level logging -# Use the environment variable INVENTREE_DB_LOGGING +# Enable database-level logging, or use the environment variable INVENTREE_DB_LOGGING db_logging: False -# Select default system language (default is 'en-us') -# Use the environment variable INVENTREE_LANGUAGE +# Select default system language , or use the environment variable INVENTREE_LANGUAGE language: en-us -# System time-zone (default is UTC) -# Reference: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones +# System time-zone (default is UTC). Reference: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones timezone: UTC -# Base URL for the InvenTree server -# Use the environment variable INVENTREE_SITE_URL +# Base URL for the InvenTree server (or use the environment variable INVENTREE_SITE_URL) # site_url: 'http://localhost:8000' # Base currency code (or use env var INVENTREE_BASE_CURRENCY) @@ -149,15 +110,13 @@ sentry_enabled: False # resources: # CUSTOM_KEY: 'CUSTOM_VALUE' -# Set this variable to True to enable InvenTree Plugins -# Alternatively, use the environment variable INVENTREE_PLUGINS_ENABLED +# Set this variable to True to enable InvenTree Plugins, or use the environment variable INVENTREE_PLUGINS_ENABLED plugins_enabled: False #plugin_noinstall: True #plugin_file: '/path/to/plugins.txt' #plugin_dir: '/path/to/plugins/' -# Set this variable to True to enable auto-migrations -# Alternatively, use the environment variable INVENTREE_AUTO_UPDATE +# Set this variable to True to enable auto-migrations, or use the environment variable INVENTREE_AUTO_UPDATE auto_update: False # Allowed hosts (see ALLOWED_HOSTS in Django settings documentation) @@ -248,56 +207,13 @@ remote_login_header: HTTP_REMOTE_USER # github: # VERIFIED_EMAIL: true -# Add LDAP support -# ldap: -# enabled: false -# debug: false # enable debug mode to troubleshoot ldap configuration -# server_uri: ldaps://example.org -# bind_dn: cn=admin,dc=example,dc=org -# bind_password: admin_password -# search_base_dn: cn=Users,dc=example,dc=org - -# # enable TLS encryption over the standard LDAP port, -# # see: https://django-auth-ldap.readthedocs.io/en/latest/reference.html#auth-ldap-start-tls -# # start_tls: false - -# # uncomment if you want to use direct bind, bind_dn and bin_password is not necessary then -# # user_dn_template: "uid=%(user)s,dc=example,dc=org" - -# # uncomment to set advanced global options, see https://www.python-ldap.org/en/latest/reference/ldap.html#ldap-options -# # for all available options (keys and values starting with OPT_ get automatically converted to python-ldap keys) -# # global_options: -# # OPT_X_TLS_REQUIRE_CERT: OPT_X_TLS_NEVER -# # OPT_X_TLS_CACERTFILE: /opt/inventree/ldapca.pem - -# # uncomment for advanced filter search, default: uid=%(user)s -# # search_filter_str: - -# # uncomment for advanced user attribute mapping (in the format : ) -# # user_attr_map: -# # first_name: givenName -# # last_name: sn -# # email: mail - -# # always update the user on each login, default: true -# # always_update_user: true - -# # cache timeout to reduce traffic with LDAP server, default: 3600 (1h) -# # cache_timeout: 3600 - -# # LDAP group support -# # group_search: ou=groups,dc=example,dc=com -# # require_group: cn=inventree_allow,ou=groups,dc=example,dc=com -# # deny_group: cn=inventree_deny,ou=groups,dc=example,dc=com -# # Set staff/superuser flag based on LDAP group membership -# # user_flags_by_group: -# # is_staff: cn=inventree_staff,ou=groups,dc=example,dc=com -# # is_superuser: cn=inventree_superuser,ou=groups,dc=example,dc=com +# Add LDAP support (refer to the documentation for available options) +# Ref: https://docs.inventree.org/en/stable/start/advanced/#ldap +ldap: + enabled: false # Customization options -# Add custom messages to the login page or main interface navbar or exchange the logo -# Use environment variable INVENTREE_CUSTOMIZE or INVENTREE_CUSTOM_LOGO -# Logo and splash paths and filenames must be relative to the static_root directory +# Ref: https://docs.inventree.org/en/stable/start/config/#customization-options # customize: # login_message: InvenTree demo instance - Click here for login details # navbar_message:
InvenTree demo mode
diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index e2182b163541..3df52b22cac2 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -851,7 +851,9 @@ def get_starred(self, part) -> bool: starred = serializers.SerializerMethodField() # PrimaryKeyRelated fields (Note: enforcing field type here results in much faster queries, somehow...) - category = serializers.PrimaryKeyRelatedField(queryset=PartCategory.objects.all()) + category = serializers.PrimaryKeyRelatedField( + queryset=PartCategory.objects.all(), required=False, allow_null=True + ) # Pricing fields pricing_min = InvenTree.serializers.InvenTreeMoneySerializer( diff --git a/src/backend/InvenTree/part/test_api.py b/src/backend/InvenTree/part/test_api.py index e4372f379b97..faaacfb0a5a9 100644 --- a/src/backend/InvenTree/part/test_api.py +++ b/src/backend/InvenTree/part/test_api.py @@ -541,13 +541,58 @@ def test_part(self): category = actions['category'] self.assertEqual(category['type'], 'related field') - self.assertTrue(category['required']) + self.assertFalse(category['required']) self.assertFalse(category['read_only']) self.assertEqual(category['label'], 'Category') self.assertEqual(category['model'], 'partcategory') self.assertEqual(category['api_url'], reverse('api-part-category-list')) self.assertEqual(category['help_text'], 'Part category') + def test_part_label_translation(self): + """Test that 'label' values are correctly translated.""" + response = self.options(reverse('api-part-list')) + + labels = { + 'IPN': 'IPN', + 'category': 'Category', + 'assembly': 'Assembly', + 'ordering': 'On Order', + 'stock_item_count': 'Stock Items', + } + + help_text = { + 'IPN': 'Internal Part Number', + 'active': 'Is this part active?', + 'barcode_hash': 'Unique hash of barcode data', + 'category': 'Part category', + } + + # Check basic return values + for field, value in labels.items(): + self.assertEqual(response.data['actions']['POST'][field]['label'], value) + + for field, value in help_text.items(): + self.assertEqual( + response.data['actions']['POST'][field]['help_text'], value + ) + + # Check again, with a different locale + response = self.options( + reverse('api-part-list'), headers={'Accept-Language': 'de'} + ) + + translated = { + 'IPN': 'IPN (Interne Produktnummer)', + 'category': 'Kategorie', + 'assembly': 'Baugruppe', + 'ordering': 'Bestellt', + 'stock_item_count': 'Lagerartikel', + } + + for field, value in translated.items(): + label = response.data['actions']['POST'][field]['label'] + self.assertEqual(label, value) + def test_category(self): """Test the PartCategory API OPTIONS endpoint.""" actions = self.getActions(reverse('api-part-category-list')) diff --git a/src/backend/InvenTree/plugin/base/event/events.py b/src/backend/InvenTree/plugin/base/event/events.py index eaaa1bfd9663..8206c72d73fc 100644 --- a/src/backend/InvenTree/plugin/base/event/events.py +++ b/src/backend/InvenTree/plugin/base/event/events.py @@ -60,6 +60,9 @@ def register_event(event, *args, **kwargs): # Determine if there are any plugins which are interested in responding if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_EVENTS'): + # Check if the plugin registry needs to be reloaded + registry.check_reload() + with transaction.atomic(): for slug, plugin in registry.plugins.items(): if not plugin.mixin_enabled('events'): diff --git a/src/backend/InvenTree/plugin/base/label/mixins.py b/src/backend/InvenTree/plugin/base/label/mixins.py index 68236ec29fa2..a60c1b0d7129 100644 --- a/src/backend/InvenTree/plugin/base/label/mixins.py +++ b/src/backend/InvenTree/plugin/base/label/mixins.py @@ -147,6 +147,9 @@ def print_labels( N = len(items) + if N <= 0: + raise ValidationError(_('No items provided to print')) + # Generate a label output for each provided item for item in items: context = label.get_context(item, request) @@ -177,9 +180,13 @@ def print_labels( self.print_label(**print_args) else: # Offload the print task to the background worker + # Exclude the 'pdf_file' object - cannot be pickled + print_args.pop('pdf_file', None) + + # Exclude the 'context' object - cannot be pickled + print_args.pop('context', None) - kwargs.pop('pdf_file', None) offload_task(plugin_label.print_label, self.plugin_slug(), **print_args) # Update the progress of the print job diff --git a/src/backend/InvenTree/plugin/serializers.py b/src/backend/InvenTree/plugin/serializers.py index ffe9b066e266..aa4deffa1af5 100644 --- a/src/backend/InvenTree/plugin/serializers.py +++ b/src/backend/InvenTree/plugin/serializers.py @@ -150,6 +150,11 @@ class PluginConfigEmptySerializer(serializers.Serializer): class PluginReloadSerializer(serializers.Serializer): """Serializer for remotely forcing plugin registry reload.""" + class Meta: + """Meta for serializer.""" + + fields = ['full_reload', 'force_reload', 'collect_plugins'] + full_reload = serializers.BooleanField( required=False, default=False, @@ -189,6 +194,11 @@ class PluginActivateSerializer(serializers.Serializer): model = PluginConfig + class Meta: + """Metaclass for serializer.""" + + fields = ['active'] + active = serializers.BooleanField( required=False, default=True, @@ -213,6 +223,11 @@ def update(self, instance, validated_data): class PluginUninstallSerializer(serializers.Serializer): """Serializer for uninstalling a plugin.""" + class Meta: + """Metaclass for serializer.""" + + fields = ['delete_config'] + delete_config = serializers.BooleanField( required=False, default=True, @@ -253,6 +268,11 @@ class NotificationUserSettingSerializer(GenericReferencedSettingSerializer): class PluginRegistryErrorSerializer(serializers.Serializer): """Serializer for a plugin registry error.""" + class Meta: + """Meta for serializer.""" + + fields = ['stage', 'name', 'message'] + stage = serializers.CharField() name = serializers.CharField() message = serializers.CharField() diff --git a/src/backend/InvenTree/report/api.py b/src/backend/InvenTree/report/api.py index d710359b5107..1782d95ed90d 100644 --- a/src/backend/InvenTree/report/api.py +++ b/src/backend/InvenTree/report/api.py @@ -150,7 +150,7 @@ def get_plugin_class(self, plugin_slug: str, raise_error=False): """Return the plugin class for the given plugin key.""" from plugin.models import PluginConfig - if plugin_slug is None: + if not plugin_slug: # Use the default label printing plugin plugin_slug = InvenTreeLabelPlugin.NAME.lower() @@ -196,10 +196,10 @@ def get_serializer(self, *args, **kwargs): # Plugin information provided? if self.request: - plugin_key = self.request.data.get('plugin', None) + plugin_key = self.request.data.get('plugin', '') # Legacy url based lookup if not plugin_key: - plugin_key = self.request.query_params.get('plugin', None) + plugin_key = self.request.query_params.get('plugin', '') plugin = self.get_plugin_class(plugin_key) plugin_serializer = self.get_plugin_serializer(plugin) diff --git a/src/backend/InvenTree/report/models.py b/src/backend/InvenTree/report/models.py index 053e6cdaebee..05584b55884d 100644 --- a/src/backend/InvenTree/report/models.py +++ b/src/backend/InvenTree/report/models.py @@ -246,7 +246,6 @@ def base_context(self, request=None): 'base_url': get_base_url(request=request), 'date': InvenTree.helpers.current_date(), 'datetime': InvenTree.helpers.current_time(), - 'request': request, 'template': self, 'template_description': self.description, 'template_name': self.name, diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index 17a4385a39a0..f1787fb34f3d 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -2158,7 +2158,7 @@ def testResultMap(self, **kwargs): def testResultList(self, **kwargs): """Return a list of test-result objects for this StockItem.""" - return self.testResultMap(**kwargs).values() + return list(self.testResultMap(**kwargs).values()) def requiredTestStatus(self): """Return the status of the tests required for this StockItem. diff --git a/src/backend/InvenTree/templates/js/translated/forms.js b/src/backend/InvenTree/templates/js/translated/forms.js index dd187dc82905..56ae5a2c3c41 100644 --- a/src/backend/InvenTree/templates/js/translated/forms.js +++ b/src/backend/InvenTree/templates/js/translated/forms.js @@ -2030,7 +2030,7 @@ function initializeRelatedField(field, fields, options={}) { // Each 'row' must have the 'id' attribute for (var idx = 0; idx < data.length; idx++) { - data[idx].id = data[idx].pk; + data[idx].id = data[idx][field.pk_field ?? 'pk']; } // Ref: https://select2.org/data-sources/formats @@ -2054,7 +2054,9 @@ function initializeRelatedField(field, fields, options={}) { data = item.element.instance; } - if (!data.pk) { + const pkField = field.pk_field ?? 'pk'; + + if (!data[pkField]) { return $(searching()); } @@ -2075,6 +2077,8 @@ function initializeRelatedField(field, fields, options={}) { // Or, use the raw 'item' data as a backup var data = item; + const pkField = field.pk_field ?? 'pk'; + if (item.element && item.element.instance) { data = item.element.instance; } @@ -2084,7 +2088,7 @@ function initializeRelatedField(field, fields, options={}) { field.onSelect(data, field, options); } - if (!data.pk) { + if (!data[pkField]) { return field.placeholder || ''; } @@ -2246,7 +2250,9 @@ function setRelatedFieldData(name, data, options={}) { var select = getFormFieldElement(name, options); - var option = new Option(name, data.pk, true, true); + const pkField = options?.fields[name]?.pk_field ?? 'pk'; + + var option = new Option(name, data[pkField], true, true); // Assign the JSON data to the 'instance' attribute, // so we can access and render it later diff --git a/src/backend/InvenTree/templates/js/translated/label.js b/src/backend/InvenTree/templates/js/translated/label.js index 0366f5b8b1ff..fff34aecf677 100644 --- a/src/backend/InvenTree/templates/js/translated/label.js +++ b/src/backend/InvenTree/templates/js/translated/label.js @@ -48,7 +48,7 @@ const defaultLabelTemplates = { */ function printLabels(options) { - let pluginId = -1; + let plugin_name = ''; if (!options.items || options.items.length == 0) { showAlertDialog( @@ -67,14 +67,13 @@ function printLabels(options) { items: item_string, }; - function getPrintingFields(plugin_id, callback) { - let url = '{% url "api-label-print" %}' + `?plugin=${plugin_id}`; + function getPrintingFields(plugin_slug, callback) { + + let url = '{% url "api-label-print" %}' + `?plugin=${plugin_slug}`; inventreeGet( url, - { - plugin: plugin_id, - }, + {}, { method: 'OPTIONS', success: function(response) { @@ -88,11 +87,11 @@ function printLabels(options) { // Callback when a particular label printing plugin is selected function onPluginSelected(value, name, field, formOptions) { - if (value == pluginId) { + if (value == plugin_name) { return; } - pluginId = value; + plugin_name = value; // Request new printing options for the selected plugin getPrintingFields(value, function(fields) { @@ -108,7 +107,9 @@ function printLabels(options) { const baseFields = { template: {}, - plugin: {}, + plugin: { + idField: 'key', + }, items: {} }; diff --git a/src/frontend/src/components/buttons/PrintingActions.tsx b/src/frontend/src/components/buttons/PrintingActions.tsx index 81c5d8ddd69e..3f14e49eebab 100644 --- a/src/frontend/src/components/buttons/PrintingActions.tsx +++ b/src/frontend/src/components/buttons/PrintingActions.tsx @@ -15,11 +15,13 @@ import { ActionDropdown } from '../items/ActionDropdown'; export function PrintingActions({ items, + hidden, enableLabels, enableReports, modelType }: { items: number[]; + hidden?: boolean; enableLabels?: boolean; enableReports?: boolean; modelType?: ModelType; @@ -79,8 +81,6 @@ export function PrintingActions({ mixin: 'labels' }, onValueChange: (value: string, record?: any) => { - console.log('onValueChange:', value, record); - if (record?.key && record?.key != pluginKey) { setPluginKey(record.key); } @@ -100,6 +100,7 @@ export function PrintingActions({ }, successMessage: t`Label printing completed successfully`, onFormSuccess: (response: any) => { + setPluginKey(''); if (!response.complete) { // TODO: Periodically check for completion (requires server-side changes) notifications.show({ @@ -164,28 +165,30 @@ export function PrintingActions({ } return ( - <> - {reportModal.modal} - {labelModal.modal} - } - disabled={!enabled} - actions={[ - { - name: t`Print Labels`, - icon: , - onClick: () => labelModal.open(), - hidden: !enableLabels - }, - { - name: t`Print Reports`, - icon: , - onClick: () => reportModal.open(), - hidden: !enableReports - } - ]} - /> - + !hidden && ( + <> + {reportModal.modal} + {labelModal.modal} + } + disabled={!enabled} + actions={[ + { + name: t`Print Labels`, + icon: , + onClick: () => labelModal.open(), + hidden: !enableLabels + }, + { + name: t`Print Reports`, + icon: , + onClick: () => reportModal.open(), + hidden: !enableReports + } + ]} + /> + + ) ); } diff --git a/src/frontend/src/components/forms/ApiForm.tsx b/src/frontend/src/components/forms/ApiForm.tsx index 2317f3e8e01b..c9b660174eb7 100644 --- a/src/frontend/src/components/forms/ApiForm.tsx +++ b/src/frontend/src/components/forms/ApiForm.tsx @@ -144,10 +144,12 @@ export function OptionsApiForm({ if (error.response) { invalidResponse(error.response.status); } else { + notifications.hide('form-error'); notifications.show({ title: t`Form Error`, message: error.message, - color: 'red' + color: 'red', + id: 'form-error' }); } return false; diff --git a/src/frontend/src/components/nav/BreadcrumbList.tsx b/src/frontend/src/components/nav/BreadcrumbList.tsx index dbd5ca0f30f8..15c737497511 100644 --- a/src/frontend/src/components/nav/BreadcrumbList.tsx +++ b/src/frontend/src/components/nav/BreadcrumbList.tsx @@ -10,6 +10,8 @@ import { IconMenu2 } from '@tabler/icons-react'; import { useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; +import { navigateToLink } from '../../functions/navigation'; + export type Breadcrumb = { name: string; url: string; @@ -57,7 +59,10 @@ export function BreadcrumbList({ return ( breadcrumb.url && navigate(breadcrumb.url)} + onClick={(event: any) => + breadcrumb.url && + navigateToLink(breadcrumb.url, navigate, event) + } > {breadcrumb.name} diff --git a/src/frontend/src/components/nav/Header.tsx b/src/frontend/src/components/nav/Header.tsx index f5cdd0786537..af8c8b11267f 100644 --- a/src/frontend/src/components/nav/Header.tsx +++ b/src/frontend/src/components/nav/Header.tsx @@ -8,6 +8,7 @@ import { useMatch, useNavigate } from 'react-router-dom'; import { api } from '../../App'; import { navTabs as mainNavTabs } from '../../defaults/links'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; +import { navigateToLink } from '../../functions/navigation'; import * as classes from '../../main.css'; import { apiUrl } from '../../states/ApiState'; import { useLocalState } from '../../states/LocalState'; @@ -141,13 +142,16 @@ function NavTabs() { tab: classes.tab }} value={tabValue} - onChange={(value) => - value == '/' ? navigate('/') : navigate(`/${value}`) - } > {mainNavTabs.map((tab) => ( - + + navigateToLink(`/${tab.name}`, navigate, event) + } + > {tab.text} ))} diff --git a/src/frontend/src/components/nav/PanelGroup.tsx b/src/frontend/src/components/nav/PanelGroup.tsx index afc9be16d664..5523de022c66 100644 --- a/src/frontend/src/components/nav/PanelGroup.tsx +++ b/src/frontend/src/components/nav/PanelGroup.tsx @@ -10,15 +10,17 @@ import { IconLayoutSidebarLeftCollapse, IconLayoutSidebarRightCollapse } from '@tabler/icons-react'; -import { ReactNode, useEffect, useMemo, useState } from 'react'; +import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; import { Navigate, Route, Routes, + useLocation, useNavigate, useParams } from 'react-router-dom'; +import { navigateToLink } from '../../functions/navigation'; import { useLocalState } from '../../states/LocalState'; import { Boundary } from '../Boundary'; import { PlaceholderPanel } from '../items/Placeholder'; @@ -52,6 +54,7 @@ function BasePanelGroup({ selectedPanel, collapsible = true }: Readonly): ReactNode { + const location = useLocation(); const navigate = useNavigate(); const { panel } = useParams(); @@ -72,19 +75,27 @@ function BasePanelGroup({ }, [setLastUsedPanel]); // Callback when the active panel changes - function handlePanelChange(panel: string | null) { - if (activePanels.findIndex((p) => p.name === panel) === -1) { - setLastUsedPanel(''); - return navigate('../'); - } - - navigate(`../${panel}`); - - // Optionally call external callback hook - if (panel && onPanelChange) { - onPanelChange(panel); - } - } + const handlePanelChange = useCallback( + (panel: string | null, event?: any) => { + if (activePanels.findIndex((p) => p.name === panel) === -1) { + setLastUsedPanel(''); + return navigate('../'); + } + + if (event && (event?.ctrlKey || event?.shiftKey)) { + const url = `${location.pathname}/../${panel}`; + navigateToLink(url, navigate, event); + } else { + navigate(`../${panel}`); + } + + // Optionally call external callback hook + if (panel && onPanelChange) { + onPanelChange(panel); + } + }, + [activePanels, setLastUsedPanel, navigate, location, onPanelChange] + ); // if the selected panel state changes update the current panel useEffect(() => { @@ -129,6 +140,9 @@ function BasePanelGroup({ hidden={panel.hidden} disabled={panel.disabled} style={{ cursor: panel.disabled ? 'unset' : 'pointer' }} + onClick={(event: any) => + handlePanelChange(panel.name, event) + } > {expanded && panel.label} diff --git a/src/frontend/src/pages/stock/LocationDetail.tsx b/src/frontend/src/pages/stock/LocationDetail.tsx index e3c83dea6d23..462faa7075c7 100644 --- a/src/frontend/src/pages/stock/LocationDetail.tsx +++ b/src/frontend/src/pages/stock/LocationDetail.tsx @@ -294,6 +294,7 @@ export default function Stock() {