Merge branch 'master' into matmair/issue11460

This commit is contained in:
Matthias Mair 2026-04-03 02:03:55 +02:00 committed by GitHub
commit 86f58218bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
100 changed files with 27288 additions and 26402 deletions

View File

@ -13,5 +13,5 @@ runs:
invoke export-records -f data.json
python3 ./src/backend/InvenTree/manage.py flush --noinput
invoke migrate
invoke import-records -c -f data.json --force --strict
invoke import-records -c -f data.json --force --strict
invoke import-records -c -f data.json --strict
invoke import-records -c -f data.json --strict

101
.github/scripts/check_exported_data.py vendored Normal file
View File

@ -0,0 +1,101 @@
"""Script to check a data file exported using the 'export-records' command.
This script is intended to be used as part of the CI workflow,
in conjunction with the "workflows/import_export.yaml" workflow.
In reads the exported data file, to ensure that:
- The file can be read and parsed as JSON
- The file contains the expected metadata
- The file contains the expected plugin configuration
- The file contains the expected plugin database records
"""
PLUGIN_KEY = 'dummy_app_plugin'
PLUGIN_SLUG = 'dummy-app-plugin'
import argparse
import json
import os
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Check exported data file')
parser.add_argument('datafile', help='Path to the exported data file (JSON)')
args = parser.parse_args()
if not os.path.isfile(args.datafile):
print(f'Error: File not found: {args.datafile}')
exit(1)
with open(args.datafile, encoding='utf-8') as f:
try:
data = json.load(f)
print(f'Successfully loaded data from {args.datafile}')
print(f'Number of records: {len(data)}')
except json.JSONDecodeError as e:
print(f'Error: Failed to parse JSON file: {e}')
exit(1)
found_metadata = False
found_installed_apps = False
found_plugin_config = False
plugin_data_records = {}
# Inspect the data and check that it has the expected structure and content.
for entry in data:
# Check metadata entry for expected values
if entry.get('metadata', False):
print('Found metadata entry')
found_metadata = True
expected_apps = ['InvenTree', 'allauth', 'dbbackup', PLUGIN_KEY]
apps = entry.get('installed_apps', [])
for app in expected_apps:
if app not in apps:
print(f'- Expected app "{app}" not found in installed apps list')
exit(1)
found_installed_apps = True
elif entry.get('model', None) == 'plugin.pluginconfig':
key = entry['fields']['key']
if key == PLUGIN_SLUG:
print(f'Found plugin configuration for plugin "{PLUGIN_KEY}"')
found_plugin_config = True
elif entry.get('model', None) == f'{PLUGIN_KEY}.examplemodel':
key = entry['fields']['key']
value = entry['fields']['value']
plugin_data_records[key] = value
if not found_metadata:
print('Error: No metadata entry found in exported data')
exit(1)
if not found_installed_apps:
print(
f'Error: Plugin "{PLUGIN_KEY}" not found in installed apps list in metadata'
)
exit(1)
if not found_plugin_config:
print(f'Error: No plugin configuration found for plugin "{PLUGIN_KEY}"')
exit(1)
# Check the extracted plugin records
expected_keys = ['alpha', 'beta', 'gamma', 'delta']
for key in expected_keys:
if key not in plugin_data_records:
print(
f'Error: Expected plugin record with key "{key}" not found in exported data'
)
exit(1)
print('All checks passed successfully!')

118
.github/workflows/import_export.yaml vendored Normal file
View File

@ -0,0 +1,118 @@
# Ensure that data import / export functionality works as expected.
# - Create a dataset in a Postgres database (including plugin data)
# - Export the dataset to an agnostic format (JSON)
# - Import the dataset into a Sqlite database
name: Import / Export
on:
push:
branches-ignore: ["l10*"]
pull_request:
branches-ignore: ["l10*"]
permissions:
contents: read
env:
python_version: 3.11
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INVENTREE_DEBUG: false
INVENTREE_LOG_LEVEL: WARNING
INVENTREE_MEDIA_ROOT: /home/runner/work/InvenTree/test_inventree_media
INVENTREE_STATIC_ROOT: /home/runner/work/InvenTree/test_inventree_static
INVENTREE_BACKUP_DIR: /home/runner/work/InvenTree/test_inventree_backup
INVENTREE_SITE_URL: http://localhost:8000
INVENTREE_PLUGINS_ENABLED: true
INVENTREE_AUTO_UPDATE: true
INVENTREE_PLUGINS_MANDATORY: "dummy-app-plugin"
INVENTREE_GLOBAL_SETTINGS: '{"ENABLE_PLUGINS_APP": true}'
DATA_FILE: /home/runner/work/InvenTree/test_inventree_data.json
INVENTREE_DB_ENGINE: postgresql
INVENTREE_DB_NAME: inventree
INVENTREE_DB_USER: inventree
INVENTREE_DB_PASSWORD: password
INVENTREE_DB_HOST: "127.0.0.1"
INVENTREE_DB_PORT: 5432
jobs:
paths-filter:
name: filter
runs-on: ubuntu-latest
outputs:
server: ${{ steps.filter.outputs.server }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
with:
persist-credentials: false
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1
id: filter
with:
filters: |
server:
- 'src/backend/**'
- 'tasks.py'
test:
runs-on: ubuntu-latest
needs: paths-filter
if: needs.paths-filter.outputs.server == 'true'
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: inventree
POSTGRES_PASSWORD: password
ports:
- 5432:5432
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Environment Setup
uses: ./.github/actions/setup
with:
apt-dependency: gettext poppler-utils libpq-dev
pip-dependency: psycopg
update: true
static: false
- name: Setup Postgres Database
run: |
invoke migrate
invoke dev.setup-test -i
- name: Create Plugin Data
run: |
pip install -U inventree-dummy-app-plugin
invoke migrate
cd src/backend/InvenTree && python manage.py create_dummy_data
- name: Export Postgres Dataset
run: |
invoke export-records -o -f ${{ env.DATA_FILE }}
python .github/scripts/check_exported_data.py ${{ env.DATA_FILE }}
invoke dev.delete-data --force
- name: Update Environment Variables for Sqlite
run: |
echo "Updating environment variables for Sqlite"
echo "INVENTREE_DB_ENGINE=sqlite" >> $GITHUB_ENV
echo "INVENTREE_DB_NAME=/home/runner/work/InvenTree/test_inventree_db.sqlite3" >> $GITHUB_ENV
- name: Setup Sqlite Database
run: |
invoke migrate
test -f /home/runner/work/InvenTree/test_inventree_db.sqlite3 || (echo "Sqlite database not created" && exit 1)
- name: Import Sqlite Dataset
run: |
invoke import-records -c -f ${{ env.DATA_FILE }}
cd src/backend/InvenTree && python manage.py check_dummy_data
- name: Export Sqlite Dataset
run: |
invoke export-records -o -f ${{ env.DATA_FILE }}
python .github/scripts/check_exported_data.py ${{ env.DATA_FILE }}

View File

@ -16,7 +16,7 @@ Deploying InvenTree to production requires to knowledge of the security assumpti
2. All users are trusted - therefore user uploaded files can be assumed to be safe. There are basic checks in place to ensure that the files are not using common attack vectors but those are not exhaustive.
3. Superuser permissions are only given to trusted users and not used for daily operations. A superuser account can manipulate or extract all files on the server that the InvenTree server process have access to.
3. Superuser or staff permissions are only given to trusted users and not used for daily operations. A superuser account can manipulate or extract all files on the server that the InvenTree server process have access to. See [dangerous user flags](../settings/permissions.md#dangerous-user-flags) for more details on user permissions and flags.
4. All templates and plugins are trusted.

View File

@ -50,6 +50,17 @@ Within each role, there are four levels of available permissions:
| **Add** | The *add* permission allows the user to add / create database records associated with the particular role |
| **Delete** | The *delete* permission allows the user to delete / remove database records associated with the particular role |
## Dangerous User Flags
In addition to the above permissions, there are two special flags that can be assigned to a user:
- **Staff** - A user with the *staff* flag is able to access the admin interface, and can trigger dangerous actions that might have a security impact such as changing parsable files on the server (templates / reports / plugins). Some of these actions require the *admin* role to be assigned as well.
- **Superuser** - A user with the *superuser* flag is able to access and change all data and functions of InvenTree. A superuser can modify and access all data that the InvenTree installation / server has access to - including shell access on the server OS itself. This is a very powerful flag, and should be used with caution.
It is strongly recommended to register any users with staff / superuser flags with strong MFA methods to reduce the risk of unauthorized access. These accounts should be used with caution, and should not be used for day-to-day operations.
Practicing account tiering is strongly recommended.
## Admin Interface Permissions
If a user does not have the required permissions to perform a certain action in the admin interface, those options not be displayed.

View File

@ -554,4 +554,4 @@ To override global settings, provide a "dictionary" of settings overrides in the
| Environment Variable | Configuration File | Description | Default |
| --- | --- | --- | --- |
| GLOBAL_SETTINGS_OVERRIDES | global_settings_overrides | JSON object containing global settings overrides | *Not specified* |
| INVENTREE_GLOBAL_SETTINGS | global_settings | JSON object containing global settings overrides | *Not specified* |

View File

@ -201,3 +201,32 @@ This will load the database records from the backup file into the new database.
### Caveats
The process described here is a *suggested* procedure for migrating between incompatible database versions. However, due to the complexity of database software, there may be unforeseen complications that arise during the process.
## Migrating Plugin Data
Custom plugins may define their own database models, and thus have their own data records stored in the database. If a plugin is being migrated from one InvenTree installation to another, then the plugin data must also be migrated.
To account for this, the `export-records` and `import-records` commands have been designed to also export and import plugin data, in addition to the core InvenTree data.
### Exporting Plugin Data
When running the `export-records` command, any data records associated with plugins will also be exported, and included in the output JSON file.
### Importing Plugin Data
When running the `import-records` command, the import process will also attempt to import any plugin data records contained in the input JSON file. However, for the plugin data to be imported correctly, the following conditions must be met:
1. The plugin *code* must be present in the new InvenTree installation. Any plugins *not* installed will not have their tables created, and thus the import process will fail for those records.
2. The plugin *version* must be the same in both installations. If the plugin version is different, then the database schema may be different, and thus the import process may fail.
3. The InvenTree software version must be the same in both installations. If the InvenTree version is different, then the database schema may be different, and thus the import process may fail.
If all of the above conditions are met, then the plugin data *should* be imported correctly into the new database. To achieve this reliably, the following process steps are implemented in the `import-records` command:
1. The database is cleaned of all existing records (if the `-c` option is used).
2. The core InvenTree database migrations are run to ensure that the core database schema is correct.
3. User auth records are imported into the database
4. Common configuration records (such as global settings) are imported into the database
5. Plugin configuration records (defining which plugins are active) are imported into the database
6. Database migrations are run once more, to ensure that any plugin database schema are correctly initialized
7. The database is checked to ensure that all required apps are present (i.e. all plugins are installed and correctly activated)
8. All remaining records (including plugin data) are imported into the database

View File

@ -21,16 +21,19 @@ from django.http import StreamingHttpResponse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
import bleach
import bleach.css_sanitizer
import bleach.sanitizer
import nh3
import structlog
from bleach import clean
from djmoney.money import Money
from PIL import Image
from stdimage.models import StdImageField, StdImageFieldFile
from common.currency import currency_code_default
from InvenTree.sanitizer import (
DEAFAULT_ATTRS,
DEFAULT_CSS,
DEFAULT_PROTOCOLS,
DEFAULT_TAGS,
)
from .setting.storages import StorageBackends
from .settings import MEDIA_URL, STATIC_URL
@ -895,13 +898,13 @@ def clean_decimal(number):
def strip_html_tags(value: str, raise_error=True, field_name=None):
"""Strip HTML tags from an input string using the bleach library.
"""Strip HTML tags from an input string using the nh3 library.
If raise_error is True, a ValidationError will be thrown if HTML tags are detected
"""
value = str(value).strip()
cleaned = clean(value, strip=True, tags=[], attributes=[])
cleaned = nh3.clean(value, tags=frozenset())
# Add escaped characters back in
replacements = {'&gt;': '>', '&lt;': '<', '&amp;': '&'}
@ -961,34 +964,32 @@ def clean_markdown(value: str) -> str:
output_format='html',
)
# Bleach settings
whitelist_tags = markdownify_settings.get(
'WHITELIST_TAGS', bleach.sanitizer.ALLOWED_TAGS
)
whitelist_attrs = markdownify_settings.get(
'WHITELIST_ATTRS', bleach.sanitizer.ALLOWED_ATTRIBUTES
)
whitelist_styles = markdownify_settings.get(
'WHITELIST_STYLES', bleach.css_sanitizer.ALLOWED_CSS_PROPERTIES
)
# nh3 sanitizer settings
whitelist_tags = markdownify_settings.get('WHITELIST_TAGS', DEFAULT_TAGS)
whitelist_attrs = markdownify_settings.get('WHITELIST_ATTRS', DEAFAULT_ATTRS)
whitelist_styles = markdownify_settings.get('WHITELIST_STYLES', DEFAULT_CSS)
whitelist_protocols = markdownify_settings.get(
'WHITELIST_PROTOCOLS', bleach.sanitizer.ALLOWED_PROTOCOLS
'WHITELIST_PROTOCOLS', DEFAULT_PROTOCOLS
)
strip = markdownify_settings.get('STRIP', True)
css_sanitizer = bleach.css_sanitizer.CSSSanitizer(
allowed_css_properties=whitelist_styles
)
cleaner = bleach.Cleaner(
tags=whitelist_tags,
attributes=whitelist_attrs,
css_sanitizer=css_sanitizer,
protocols=whitelist_protocols,
strip=strip,
)
# Convert bleach-style attributes (list or dict) to nh3-compatible dict format
if isinstance(whitelist_attrs, (list, tuple, set, frozenset)):
attrs_dict = {'*': set(whitelist_attrs)}
elif isinstance(whitelist_attrs, dict):
attrs_dict = {tag: set(allowed) for tag, allowed in whitelist_attrs.items()}
else:
attrs_dict = None
# Clean the HTML content (for comparison). This must be the same as the original content
clean_html = cleaner.clean(html)
clean_html = nh3.clean(
html,
tags=set(whitelist_tags),
attributes=attrs_dict,
url_schemes=set(whitelist_protocols),
filter_style_properties=set(whitelist_styles),
link_rel=None,
strip_comments=True,
)
if html != clean_html:
raise ValidationError(_('Data contains prohibited markdown content'))

View File

@ -19,7 +19,7 @@ from InvenTree.serializers import FilterableSerializerMixin
class CleanMixin:
"""Model mixin class which cleans inputs using the Mozilla bleach tools."""
"""Model mixin class which cleans inputs using nh3."""
# Define a list of field names which will *not* be cleaned
SAFE_FIELDS = []
@ -52,16 +52,7 @@ class CleanMixin:
return Response(serializer.data)
def clean_string(self, field: str, data: str) -> str:
"""Clean / sanitize a single input string.
Note that this function will *allow* orphaned <>& characters,
which would normally be escaped by bleach.
Nominally, the only thing that will be "cleaned" will be HTML tags
Ref: https://github.com/mozilla/bleach/issues/192
"""
"""Clean / sanitize a single input string."""
cleaned = data
# By default, newline characters are removed
@ -101,7 +92,7 @@ class CleanMixin:
def clean_data(self, data: dict) -> dict:
"""Clean / sanitize data.
This uses Mozilla's bleach under the hood to disable certain html tags by
This uses nh3 under the hood to disable certain html tags by
encoding them - this leads to script tags etc. to not work.
The results can be longer then the input; might make some character combinations
`ugly`. Prevents XSS on the server-level.

View File

@ -125,6 +125,7 @@ def isGeneratingSchema():
'qcluster',
'check',
'shell',
'help',
]
if any(cmd in sys.argv for cmd in excluded_commands):
@ -132,12 +133,14 @@ def isGeneratingSchema():
included_commands = [
'schema',
'spectactular',
# schema adjacent calls
'export_settings_definitions',
'export_tags',
'export_filters',
'export_report_context',
]
if any(cmd in sys.argv for cmd in included_commands):
return True
@ -185,11 +188,38 @@ def isInMainThread():
return not isInWorkerThread()
def readOnlyCommands():
"""Return a list of read-only management commands which should not trigger database writes."""
return [
'help',
'check',
'shell',
'sqlflush',
'list_apps',
'wait_for_db',
'spectactular',
'makemessages',
'collectstatic',
'showmigrations',
'compilemessages',
]
def isReadOnlyCommand():
"""Return True if the current command is a read-only command, which should not trigger any database writes."""
return any(cmd in sys.argv for cmd in readOnlyCommands())
def canAppAccessDatabase(
allow_test: bool = False, allow_plugins: bool = False, allow_shell: bool = False
):
"""Returns True if the apps.py file can access database records.
Arguments:
allow_test: If True, override checks and allow database access during testing mode
allow_plugins: If True, override checks and allow database access during plugin loading
allow_shell: If True, override checks and allow database access during shell sessions
There are some circumstances where we don't want the ready function in apps.py
to touch the database
"""
@ -198,7 +228,7 @@ def canAppAccessDatabase(
return False
# Prevent database access if we are importing data
if isImportingData():
if not allow_plugins and isImportingData():
return False
# Prevent database access if we are rebuilding data
@ -212,13 +242,13 @@ def canAppAccessDatabase(
# If any of the following management commands are being executed,
# prevent custom "on load" code from running!
excluded_commands = [
'check',
'createsuperuser',
'wait_for_db',
'makemessages',
'compilemessages',
'spectactular',
'createsuperuser',
'collectstatic',
'makemessages',
'spectactular',
'wait_for_db',
'check',
]
if not allow_shell:

View File

@ -1,7 +1,66 @@
"""Functions to sanitize user input files."""
from bleach import clean
from bleach.css_sanitizer import CSSSanitizer
import nh3
# Allowed CSS properties for SVG sanitization (combines general CSS and SVG-specific properties)
_SVG_ALLOWED_CSS_PROPERTIES = frozenset([
# General CSS (matching bleach's original ALLOWED_CSS_PROPERTIES)
'azimuth',
'background-color',
'border-bottom-color',
'border-collapse',
'border-color',
'border-left-color',
'border-right-color',
'border-top-color',
'clear',
'color',
'cursor',
'direction',
'display',
'elevation',
'float',
'font',
'font-family',
'font-size',
'font-style',
'font-variant',
'font-weight',
'height',
'letter-spacing',
'line-height',
'overflow',
'pause',
'pause-after',
'pause-before',
'pitch',
'pitch-range',
'richness',
'speak',
'speak-header',
'speak-numeral',
'speak-punctuation',
'speech-rate',
'stress',
'text-align',
'text-decoration',
'text-indent',
'unicode-bidi',
'vertical-align',
'voice-family',
'volume',
'white-space',
'width',
# SVG-specific CSS (matching bleach's ALLOWED_SVG_PROPERTIES)
'fill',
'fill-opacity',
'fill-rule',
'stroke',
'stroke-linecap',
'stroke-linejoin',
'stroke-opacity',
'stroke-width',
])
ALLOWED_ELEMENTS_SVG = [
'a',
@ -184,6 +243,74 @@ ALLOWED_ATTRIBUTES_SVG = [
'style',
]
# Default allowlists (matching bleach's original defaults)
# TODO: I do not see us needing a bunch of these but I do not want to introduce a breaking change; we might want to narroy this down with the next breaking change
DEFAULT_TAGS = frozenset([
'a',
'abbr',
'acronym',
'b',
'blockquote',
'code',
'em',
'i',
'li',
'ol',
'strong',
'ul',
])
DEAFAULT_ATTRS = {'a': {'href', 'title'}, 'abbr': {'title'}, 'acronym': {'title'}}
DEFAULT_CSS = frozenset([
'azimuth',
'background-color',
'border-bottom-color',
'border-collapse',
'border-color',
'border-left-color',
'border-right-color',
'border-top-color',
'clear',
'color',
'cursor',
'direction',
'display',
'elevation',
'float',
'font',
'font-family',
'font-size',
'font-style',
'font-variant',
'font-weight',
'height',
'letter-spacing',
'line-height',
'overflow',
'pause',
'pause-after',
'pause-before',
'pitch',
'pitch-range',
'richness',
'speak',
'speak-header',
'speak-numeral',
'speak-punctuation',
'speech-rate',
'stress',
'text-align',
'text-decoration',
'text-indent',
'unicode-bidi',
'vertical-align',
'voice-family',
'volume',
'white-space',
'width',
])
# TODO: We might want to respect the setting EXTRA_URL_SCHEMES here but that would be breaking
DEFAULT_PROTOCOLS = frozenset(['http', 'https', 'mailto'])
def sanitize_svg(
file_data,
@ -206,13 +333,16 @@ def sanitize_svg(
if isinstance(file_data, bytes):
file_data = file_data.decode('utf-8')
cleaned = clean(
# nh3 requires attributes as dict[str, set[str]]; convert from list (allowed for all elements)
attrs_dict = {elem: set(attributes) for elem in elements}
cleaned = nh3.clean(
file_data,
tags=elements,
attributes=attributes,
strip=strip,
tags=set(elements),
attributes=attrs_dict,
filter_style_properties=_SVG_ALLOWED_CSS_PROPERTIES,
strip_comments=strip,
css_sanitizer=CSSSanitizer(),
link_rel=None,
)
return cleaned

View File

@ -194,6 +194,9 @@ PLUGINS_MANDATORY = get_setting(
'INVENTREE_PLUGINS_MANDATORY', 'plugins_mandatory', typecast=list, default_value=[]
)
if PLUGINS_MANDATORY:
logger.info('Mandatory plugins: %s', PLUGINS_MANDATORY)
PLUGINS_INSTALL_DISABLED = get_boolean_setting(
'INVENTREE_PLUGIN_NOINSTALL', 'plugin_noinstall', False
)

View File

@ -1604,11 +1604,11 @@ class SanitizerTest(TestCase):
def test_svg_sanitizer(self):
"""Test that SVGs are sanitized accordingly."""
valid_string = """<svg xmlns="http://www.w3.org/2000/svg" version="1.1" id="svg2" height="400" width="400">{0}
<path id="path1" d="m -151.78571,359.62883 v 112.76373 l 97.068507,-56.04253 V 303.14815 Z" style="fill:#ddbc91;"></path>
<path id="path1" d="m -151.78571,359.62883 v 112.76373 l 97.068507,-56.04253 V 303.14815 Z" style="fill:#ddbc91"></path>
</svg>"""
dangerous_string = valid_string.format('<script>alert();</script>')
# Test that valid string
# Test that valid string passes through unchanged
self.assertEqual(valid_string, sanitize_svg(valid_string))
# Test that invalid string is cleaned

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -276,7 +276,7 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
# There should not be any templates left at this point
self.assertEqual(PartCategoryParameterTemplate.objects.count(), 0)
def test_bleach(self):
def test_sanitizer(self):
"""Test that the data cleaning functionality is working.
This helps to protect against XSS injection

View File

@ -222,13 +222,18 @@ class PluginsRegistry:
import InvenTree.ready
from plugin.models import PluginConfig
if InvenTree.ready.isImportingData():
return None
# Under certain circumstances, we want to avoid creating new PluginConfig instances in the database
can_create = (
InvenTree.ready.canAppAccessDatabase(
allow_plugins=False, allow_shell=True, allow_test=True
)
and not InvenTree.ready.isReadOnlyCommand()
)
try:
cfg = PluginConfig.objects.filter(key=slug).first()
if not cfg:
if not cfg and can_create:
logger.debug(
"get_plugin_config: Creating new PluginConfig for '%s'", slug
)

View File

@ -49,6 +49,9 @@ class ReportConfig(AppConfig):
if not InvenTree.ready.canAppAccessDatabase(allow_test=False):
return # pragma: no cover
if InvenTree.ready.isReadOnlyCommand():
return # pragma: no cover
with maintenance_mode_on():
try:
self.create_default_labels()

View File

@ -33,7 +33,7 @@ def image_data(img, fmt='PNG') -> str:
def clean_barcode(data):
"""Return a 'cleaned' string for encoding into a barcode / qrcode.
- This function runs the data through bleach, and removes any malicious HTML content.
- This function sanitizes the data using nh3, and removes any malicious HTML content.
- Used to render raw barcode data into the rendered HTML templates
"""
from InvenTree.helpers import strip_html_tags

View File

@ -24,7 +24,7 @@ from rest_framework.authtoken.models import Token as AuthToken
import InvenTree.helpers
import InvenTree.models
from common.settings import get_global_setting
from InvenTree.ready import isImportingData
from InvenTree.ready import isImportingData, isReadOnlyCommand
from .ruleset import RULESET_CHOICES, get_ruleset_models
@ -463,7 +463,7 @@ class Owner(models.Model):
def create_owner(sender, instance, **kwargs):
"""Callback function to create a new owner instance after either a new group or user instance is saved."""
# Ignore during data import process to avoid data duplication
if not isImportingData():
if not isReadOnlyCommand() and not isImportingData():
Owner.create(obj=instance)
@ -600,8 +600,8 @@ class UserProfile(InvenTree.models.MetadataMixin):
@receiver(post_save, sender=User)
def create_or_update_user_profile(sender, instance, created, **kwargs):
"""Create or update user profile when user is saved."""
# Disable profile creation if importing data from file
if isImportingData():
# Disable profile creation if importing data from file or running a read-only command
if isReadOnlyCommand() or isImportingData():
return
if created:

View File

@ -89,9 +89,9 @@ bcrypt==5.0.0 \
# via
# -c src/backend/requirements.txt
# paramiko
bleach[css]==6.3.0 \
--hash=sha256:6f3b91b1c0a02bb9a78b5a454c92506aa0fdf197e1d5e114d2e00c6f64306d22 \
--hash=sha256:fe10ec77c93ddf3d13a73b035abaac7a9f5e436513864ccdad516693213c65d6
bleach==4.1.0 \
--hash=sha256:0900d8b37eba61a802ee40ac0061f8c2b5dee29c1927dd1d233e075ebf5a71da \
--hash=sha256:4d2651ab93271d1129ac9cbc679f524565cc8a1b791909c4a51eac4446a15994
# via
# -c src/backend/requirements.txt
# django-markdownify
@ -631,9 +631,9 @@ django-maintenance-mode==0.22.0 \
# via
# -c src/backend/requirements.txt
# -r src/backend/requirements.in
django-markdownify==0.9.6 \
--hash=sha256:9863b2bfa6d159ad1423dc93bf0d6eadc6413776de304049aa9fcfa5edd2ce1c \
--hash=sha256:edcf47b2026d55a8439049d35c8b54e11066a4856c4fad1060e139cb3d2eee52
django-markdownify==0.9.1 \
--hash=sha256:06ff2994ff09ce030b50de8c6fc5b89b9c25a66796948aff55370716ca1233af \
--hash=sha256:24ba68b8a5996b6ec9632d11a3fd2e7159cb7e6becd3104e0a9372b5a2a148ef
# via
# -c src/backend/requirements.txt
# -r src/backend/requirements.in
@ -1277,6 +1277,37 @@ markupsafe==3.0.3 \
# via
# -c src/backend/requirements.txt
# jinja2
nh3==0.3.4 \
--hash=sha256:07999b998bf89692738f15c0eac76a416382932f855709e0b7488b595c30ec89 \
--hash=sha256:0961a27dc2057c38d0364cb05880e1997ae1c80220cbc847db63213720b8f304 \
--hash=sha256:0d825722a1e8cbc87d7ca1e47ffb1d2a6cf343ad4c1b8465becf7cadcabcdfd0 \
--hash=sha256:18a2e44ccb29cbb45071b8f3f2dab9ebfb41a6516f328f91f1f1fd18196239a4 \
--hash=sha256:3390e4333883673a684ce16c1716b481e91782d6f56dec5c85fed9feedb23382 \
--hash=sha256:41e46b3499918ab6128b6421677b316e79869d0c140da24069d220a94f4e72d1 \
--hash=sha256:43ad4eedee7e049b9069bc015b7b095d320ed6d167ecec111f877de1540656e9 \
--hash=sha256:47d749d99ae005ab19517224140b280dd56e77b33afb82f9b600e106d0458003 \
--hash=sha256:4aa8b43e68c26b68069a3b6cef09de166d1d7fa140cf8d77e409a46cbf742e44 \
--hash=sha256:554cc2bab281758e94d770c3fb0bf2d8be5fb403ef6b2e8841dd7c1615df7a0f \
--hash=sha256:72e4e9ca1c4bd41b4a28b0190edc2e21e3f71496acd36a0162858e1a28db3d7e \
--hash=sha256:75643c22f5092d8e209f766ee8108c400bc1e44760fc94d2d638eb138d18f853 \
--hash=sha256:7cae217f031809321db962cd7e092bda8d4e95a87f78c0226628fa6c2ea8ebc5 \
--hash=sha256:80b955d802bf365bd42e09f6c3d64567dce777d20e97968d94b3e9d9e99b265e \
--hash=sha256:87dac8d611b4a478400e0821a13b35770e88c266582f065e7249d6a37b0f86e8 \
--hash=sha256:883d5a6d6ee8078c4afc8e96e022fe579c4c265775ff6ee21e39b8c542cabab3 \
--hash=sha256:8b61058f34c2105d44d2a4d4241bacf603a1ef5c143b08766bbd0cf23830118f \
--hash=sha256:8d697e19f2995b337f648204848ac3a528eaafffc39e7ce4ac6b7a2fbe6c84af \
--hash=sha256:9337517edb7c10228252cce2898e20fb3d77e32ffaccbb3c66897927d74215a0 \
--hash=sha256:96709a379997c1b28c8974146ca660b0dcd3794f4f6d50c1ea549bab39ac6ade \
--hash=sha256:c10b1f0c741e257a5cb2978d6bac86e7c784ab20572724b20c6402c2e24bce75 \
--hash=sha256:ca90397c8d36c1535bf1988b2bed006597337843a164c7ec269dc8813f37536b \
--hash=sha256:d866701affe67a5171b916b5c076e767a74c6a9efb7fb2006eb8d3c5f9a293d5 \
--hash=sha256:d8bebcb20ab4b91858385cd98fe58046ec4a624275b45ef9b976475604f45b49 \
--hash=sha256:dbe76feaa44e2ef9436f345016012a591550e77818876a8de5c8bc2a248e08df \
--hash=sha256:f5f214618ad5eff4f2a6b13a8d4da4d9e7f37c569d90a13fb9f0caaf7d04fe21 \
--hash=sha256:f987cb56458323405e8e5ea827e1befcf141ffa0c0ac797d6d02e6b646056d9a
# via
# -c src/backend/requirements.txt
# -r src/backend/requirements.in
oauthlib==3.3.1 \
--hash=sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9 \
--hash=sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1
@ -1447,6 +1478,7 @@ packaging==26.0 \
--hash=sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529
# via
# -c src/backend/requirements.txt
# bleach
# gunicorn
# opentelemetry-instrumentation
paramiko==4.0.0 \
@ -2086,6 +2118,7 @@ six==1.17.0 \
--hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
# via
# -c src/backend/requirements.txt
# bleach
# python-dateutil
sqlparse==0.5.5 \
--hash=sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba \
@ -2106,12 +2139,11 @@ tablib[xls, xlsx, yaml]==3.9.0 \
# via
# -c src/backend/requirements.txt
# -r src/backend/requirements.in
tinycss2==1.4.0 \
--hash=sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7 \
--hash=sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289
tinycss2==1.5.1 \
--hash=sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661 \
--hash=sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957
# via
# -c src/backend/requirements.txt
# bleach
# cssselect2
# weasyprint
tinyhtml5==2.1.0 \
@ -2165,9 +2197,9 @@ wcwidth==0.6.0 \
# -c src/backend/requirements.txt
# blessed
# prettytable
weasyprint==66.0 \
--hash=sha256:82b0783b726fcd318e2c977dcdddca76515b30044bc7a830cc4fbe717582a6d0 \
--hash=sha256:da71dc87dc129ac9cffdc65e5477e90365ab9dbae45c744014ec1d06303dde40
weasyprint==68.1 \
--hash=sha256:4dc3ba63c68bbbce3e9617cb2226251c372f5ee90a8a484503b1c099da9cf5be \
--hash=sha256:d3b752049b453a5c95edb27ce78d69e9319af5a34f257fa0f4c738c701b4184e
# via
# -c src/backend/requirements.txt
# -r src/backend/requirements.in

View File

@ -14,8 +14,6 @@ django-ical # iCal export for calendar views
django-maintenance-mode # Shut down application while reloading etc.
django-mailbox # Email scraping
django-markdownify # Markdown rendering
django-mptt # Modified Preorder Tree Traversal
django-markdownify # Markdown rendering
django-money # Django app for currency management
django-mptt # Modified Preorder Tree Traversal
django-redis>=5.0.0 # Redis integration
@ -39,6 +37,7 @@ drf-spectacular # DRF API documentation
feedparser # RSS newsfeed parser
gunicorn # Gunicorn web server
jinja2 # Jinja2 templating engine
nh3 # HTML sanitization (replaces bleach)
pdf2image # PDF to image conversion
pillow # Image manipulation
pint # Unit conversion

View File

@ -87,9 +87,9 @@ bcrypt==5.0.0 \
--hash=sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822 \
--hash=sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b
# via paramiko
bleach[css]==6.3.0 \
--hash=sha256:6f3b91b1c0a02bb9a78b5a454c92506aa0fdf197e1d5e114d2e00c6f64306d22 \
--hash=sha256:fe10ec77c93ddf3d13a73b035abaac7a9f5e436513864ccdad516693213c65d6
bleach==4.1.0 \
--hash=sha256:0900d8b37eba61a802ee40ac0061f8c2b5dee29c1927dd1d233e075ebf5a71da \
--hash=sha256:4d2651ab93271d1129ac9cbc679f524565cc8a1b791909c4a51eac4446a15994
# via django-markdownify
blessed==1.33.0 \
--hash=sha256:1bc8ecac6d139286ea51ec1683433528ce75b0c60db77b7d881112bf9fc85b0f \
@ -585,9 +585,9 @@ django-maintenance-mode==0.22.0 \
--hash=sha256:502f04f845d6996e8add321186b3b9236c3702de7cb0ab14952890af6523b9e5 \
--hash=sha256:a9cf2ba79c9945bd67f98755a6cfd281869d39b3745bbb5d1f571d058657aa85
# via -r src/backend/requirements.in
django-markdownify==0.9.6 \
--hash=sha256:9863b2bfa6d159ad1423dc93bf0d6eadc6413776de304049aa9fcfa5edd2ce1c \
--hash=sha256:edcf47b2026d55a8439049d35c8b54e11066a4856c4fad1060e139cb3d2eee52
django-markdownify==0.9.1 \
--hash=sha256:06ff2994ff09ce030b50de8c6fc5b89b9c25a66796948aff55370716ca1233af \
--hash=sha256:24ba68b8a5996b6ec9632d11a3fd2e7159cb7e6becd3104e0a9372b5a2a148ef
# via -r src/backend/requirements.in
django-money==3.6.0 \
--hash=sha256:94402f2831f2726b94ef2da35b4059441b4c0aedfc47b312472200d4ffdf8d73 \
@ -1145,6 +1145,35 @@ markupsafe==3.0.3 \
--hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \
--hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50
# via jinja2
nh3==0.3.4 \
--hash=sha256:07999b998bf89692738f15c0eac76a416382932f855709e0b7488b595c30ec89 \
--hash=sha256:0961a27dc2057c38d0364cb05880e1997ae1c80220cbc847db63213720b8f304 \
--hash=sha256:0d825722a1e8cbc87d7ca1e47ffb1d2a6cf343ad4c1b8465becf7cadcabcdfd0 \
--hash=sha256:18a2e44ccb29cbb45071b8f3f2dab9ebfb41a6516f328f91f1f1fd18196239a4 \
--hash=sha256:3390e4333883673a684ce16c1716b481e91782d6f56dec5c85fed9feedb23382 \
--hash=sha256:41e46b3499918ab6128b6421677b316e79869d0c140da24069d220a94f4e72d1 \
--hash=sha256:43ad4eedee7e049b9069bc015b7b095d320ed6d167ecec111f877de1540656e9 \
--hash=sha256:47d749d99ae005ab19517224140b280dd56e77b33afb82f9b600e106d0458003 \
--hash=sha256:4aa8b43e68c26b68069a3b6cef09de166d1d7fa140cf8d77e409a46cbf742e44 \
--hash=sha256:554cc2bab281758e94d770c3fb0bf2d8be5fb403ef6b2e8841dd7c1615df7a0f \
--hash=sha256:72e4e9ca1c4bd41b4a28b0190edc2e21e3f71496acd36a0162858e1a28db3d7e \
--hash=sha256:75643c22f5092d8e209f766ee8108c400bc1e44760fc94d2d638eb138d18f853 \
--hash=sha256:7cae217f031809321db962cd7e092bda8d4e95a87f78c0226628fa6c2ea8ebc5 \
--hash=sha256:80b955d802bf365bd42e09f6c3d64567dce777d20e97968d94b3e9d9e99b265e \
--hash=sha256:87dac8d611b4a478400e0821a13b35770e88c266582f065e7249d6a37b0f86e8 \
--hash=sha256:883d5a6d6ee8078c4afc8e96e022fe579c4c265775ff6ee21e39b8c542cabab3 \
--hash=sha256:8b61058f34c2105d44d2a4d4241bacf603a1ef5c143b08766bbd0cf23830118f \
--hash=sha256:8d697e19f2995b337f648204848ac3a528eaafffc39e7ce4ac6b7a2fbe6c84af \
--hash=sha256:9337517edb7c10228252cce2898e20fb3d77e32ffaccbb3c66897927d74215a0 \
--hash=sha256:96709a379997c1b28c8974146ca660b0dcd3794f4f6d50c1ea549bab39ac6ade \
--hash=sha256:c10b1f0c741e257a5cb2978d6bac86e7c784ab20572724b20c6402c2e24bce75 \
--hash=sha256:ca90397c8d36c1535bf1988b2bed006597337843a164c7ec269dc8813f37536b \
--hash=sha256:d866701affe67a5171b916b5c076e767a74c6a9efb7fb2006eb8d3c5f9a293d5 \
--hash=sha256:d8bebcb20ab4b91858385cd98fe58046ec4a624275b45ef9b976475604f45b49 \
--hash=sha256:dbe76feaa44e2ef9436f345016012a591550e77818876a8de5c8bc2a248e08df \
--hash=sha256:f5f214618ad5eff4f2a6b13a8d4da4d9e7f37c569d90a13fb9f0caaf7d04fe21 \
--hash=sha256:f987cb56458323405e8e5ea827e1befcf141ffa0c0ac797d6d02e6b646056d9a
# via -r src/backend/requirements.in
oauthlib==3.3.1 \
--hash=sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9 \
--hash=sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1
@ -1282,6 +1311,7 @@ packaging==26.0 \
--hash=sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4 \
--hash=sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529
# via
# bleach
# gunicorn
# opentelemetry-instrumentation
paramiko==4.0.0 \
@ -1859,7 +1889,9 @@ sgmllib3k==1.0.0 \
six==1.17.0 \
--hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \
--hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
# via python-dateutil
# via
# bleach
# python-dateutil
sqlparse==0.5.5 \
--hash=sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba \
--hash=sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e
@ -1874,11 +1906,10 @@ tablib[xls, xlsx, yaml]==3.9.0 \
--hash=sha256:1b6abd8edb0f35601e04c6161d79660fdcde4abb4a54f66cc9f9054bd55d5fe2 \
--hash=sha256:eda17cd0d4dda614efc0e710227654c60ddbeb1ca92cdcfc5c3bd1fc5f5a6e4a
# via -r src/backend/requirements.in
tinycss2==1.4.0 \
--hash=sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7 \
--hash=sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289
tinycss2==1.5.1 \
--hash=sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661 \
--hash=sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957
# via
# bleach
# cssselect2
# weasyprint
tinyhtml5==2.1.0 \
@ -1926,9 +1957,9 @@ wcwidth==0.6.0 \
# via
# blessed
# prettytable
weasyprint==66.0 \
--hash=sha256:82b0783b726fcd318e2c977dcdddca76515b30044bc7a830cc4fbe717582a6d0 \
--hash=sha256:da71dc87dc129ac9cffdc65e5477e90365ab9dbae45c744014ec1d06303dde40
weasyprint==68.1 \
--hash=sha256:4dc3ba63c68bbbce3e9617cb2226251c372f5ee90a8a484503b1c099da9cf5be \
--hash=sha256:d3b752049b453a5c95edb27ce78d69e9319af5a34f257fa0f4c738c701b4184e
# via -r src/backend/requirements.in
webencodings==0.5.1 \
--hash=sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78 \

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

212
tasks.py
View File

@ -8,6 +8,7 @@ import re
import shutil
import subprocess
import sys
import tempfile
from functools import wraps
from pathlib import Path
from platform import python_version
@ -1065,39 +1066,21 @@ def update(
'exclude_plugins': 'Exclude plugin data from the output file (default = False)',
'include_sso': 'Include SSO token data in the output file (default = False)',
'include_session': 'Include user session data in the output file (default = False)',
'retain_temp': 'Retain temporary files (containing permissions) at end of process (default = False)',
},
pre=[wait],
)
def export_records(
c,
filename='data.json',
overwrite=False,
include_email=False,
include_permissions=False,
include_tokens=False,
exclude_plugins=False,
include_sso=False,
include_session=False,
retain_temp=False,
overwrite: bool = False,
include_email: bool = False,
include_permissions: bool = False,
include_tokens: bool = False,
exclude_plugins: bool = False,
include_sso: bool = False,
include_session: bool = False,
):
"""Export all database records to a file.
Write data to the file defined by filename.
If --overwrite is not set, the user will be prompted about overwriting an existing files.
If --include-permissions is not set, the file defined by filename will have permissions specified for a user or group removed.
If --delete-temp is not set, the temporary file (which includes permissions) will not be deleted. This file is named filename.tmp
For historical reasons, calling this function without any arguments will thus result in two files:
- data.json: does not include permissions
- data.json.tmp: includes permissions
If you want the script to overwrite any existing files without asking, add argument -o / --overwrite.
If you only want one file, add argument - d / --delete-temp.
If you want only one file, with permissions, then additionally add argument -i / --include-permissions
"""
"""Export all database records to a file."""
# Get an absolute path to the file
target = Path(filename)
if not target.is_absolute():
@ -1107,8 +1090,6 @@ def export_records(
check_file_existence(target, overwrite)
tmpfile = f'{target}.tmp'
excludes = content_excludes(
allow_email=include_email,
allow_tokens=include_tokens,
@ -1117,16 +1098,19 @@ def export_records(
allow_sso=include_sso,
)
cmd = f"dumpdata --natural-foreign --indent 2 --output '{tmpfile}' {excludes}"
with tempfile.NamedTemporaryFile(
suffix='.json', encoding='utf-8', mode='w+t', delete=True
) as tmpfile:
cmd = f"dumpdata --natural-foreign --indent 2 --output '{tmpfile.name}' {excludes}"
# Dump data to temporary file
manage(c, cmd, pty=True)
# Dump data to temporary file
manage(c, cmd, pty=True)
info('Running data post-processing step...')
info('Running data post-processing step...')
# Post-process the file, to remove any "permissions" specified for a user or group
with open(tmpfile, encoding='utf-8') as f_in:
data = json.loads(f_in.read())
# Post-process the file, to remove any "permissions" specified for a user or group
tmpfile.seek(0)
data = json.loads(tmpfile.read())
data_out = [
{
@ -1164,22 +1148,23 @@ def export_records(
with open(target, 'w', encoding='utf-8') as f_out:
f_out.write(json.dumps(data_out, indent=2))
if not retain_temp:
info('Removing temporary files')
os.remove(tmpfile)
success('Data export completed')
def validate_import_metadata(c, metadata: dict, strict: bool = False) -> bool:
def validate_import_metadata(
c, metadata: dict, strict: bool = False, apps: bool = True, verbose: bool = False
) -> bool:
"""Validate the metadata associated with an import file.
Arguments:
c: The context or connection object
metadata (dict): The metadata to validate
apps (bool): If True, validate that all apps listed in the metadata are installed in the current environment.
strict (bool): If True, the import process will fail if any issues are detected.
verbose (bool): If True, print detailed information during validation.
"""
info('Validating import metadata...')
if verbose:
info('Validating import metadata...')
valid = True
@ -1207,16 +1192,17 @@ def validate_import_metadata(c, metadata: dict, strict: bool = False) -> bool:
f"Source version '{source_version}' does not match the current InvenTree version '{get_inventree_version()}' - this may lead to issues with the import process"
)
local_apps = set(installed_apps(c))
source_apps = set(metadata.get('installed_apps', []))
if apps:
local_apps = set(installed_apps(c))
source_apps = set(metadata.get('installed_apps', []))
for app in source_apps:
if app not in local_apps:
metadata_issue(
f"Source app '{app}' is not installed in the current environment - this may break the import process"
)
for app in source_apps:
if app not in local_apps:
metadata_issue(
f"Source app '{app}' is not installed in the current environment - this may break the import process"
)
if valid:
if verbose and valid:
success('Metadata validation succeeded - no issues detected')
return valid
@ -1226,10 +1212,11 @@ def validate_import_metadata(c, metadata: dict, strict: bool = False) -> bool:
help={
'filename': 'Input filename',
'clear': 'Clear existing data before import',
'force': 'Force deletion of existing data without confirmation (only applies if --clear is set)',
'strict': 'Strict mode - fail if any issues are detected with the metadata (default = False)',
'retain_temp': 'Retain temporary files at end of process (default = False)',
'ignore_nonexistent': 'Ignore non-existent database models (default = False)',
'exclude_plugins': 'Exclude plugin data from the import process (default = False)',
'skip_migrations': 'Skip the migration step after clearing data (default = False)',
},
pre=[wait],
post=[rebuild_models, rebuild_thumbnails],
@ -1240,12 +1227,14 @@ def import_records(
clear: bool = False,
retain_temp: bool = False,
strict: bool = False,
force: bool = False,
exclude_plugins: bool = False,
ignore_nonexistent: bool = False,
skip_migrations: bool = False,
):
"""Import database records from a file."""
# Get an absolute path to the supplied filename
target = Path(filename)
if not target.is_absolute():
target = local_dir().joinpath(filename)
@ -1254,17 +1243,13 @@ def import_records(
sys.exit(1)
if clear:
delete_data(c, force=force, migrate=True)
delete_data(c, force=True, migrate=True)
if not skip_migrations:
migrate(c)
info(f"Importing database records from '{target}'")
# We need to load 'auth' data (users / groups) *first*
# This is due to the users.owner model, which has a ContentType foreign key
authfile = f'{target}.auth.json'
# Pre-process the data, to remove any "permissions" specified for a user or group
datafile = f'{target}.data.json'
with open(target, encoding='utf-8') as f_in:
try:
data = json.loads(f_in.read())
@ -1272,71 +1257,100 @@ def import_records(
error(f'ERROR: Failed to decode JSON file: {exc}')
sys.exit(1)
# Separate out the data into different categories, to ensure they are loaded in the correct order
auth_data = []
load_data = []
common_data = []
plugin_data = []
all_data = []
# A dict containing metadata associated with the data file
metadata = {}
def load_data(
title: str,
data: list[dict],
app: Optional[str] = None,
excludes: Optional[list[str]] = None,
) -> tempfile.NamedTemporaryFile:
"""Helper function to save data to a temporary file, and then load into the database."""
nonlocal ignore_nonexistent
nonlocal c
info(f'Loading {len(data)} {title} records...')
with tempfile.NamedTemporaryFile(
suffix='.json', mode='w', encoding='utf-8', delete=False
) as f_out:
f_out.write(json.dumps(data, indent=2))
cmd = f'loaddata {f_out.name} -v 0 --force-color'
if app:
cmd += f' --app {app}'
if ignore_nonexistent:
cmd += ' --ignorenonexistent'
# A set of content types to exclude from the import process
if excludes:
cmd += f' -i {excludes}'
manage(c, cmd, pty=True)
# Iterate through each entry in the provided data file, and separate out into different categories based on the model type
for entry in data:
# Metadata needs to be extracted first
if entry.get('metadata', False):
metadata = entry
continue
if 'model' in entry:
if model := entry.get('model', None):
# Clear out any permissions specified for a group
if entry['model'] == 'auth.group':
if model == 'auth.group':
entry['fields']['permissions'] = []
# Clear out any permissions specified for a user
if entry['model'] == 'auth.user':
if model == 'auth.user':
entry['fields']['user_permissions'] = []
# Save auth data for later
if entry['model'].startswith('auth.'):
# Handle certain model types separately, to ensure they are loaded in the correct order
if model.startswith('auth.'):
auth_data.append(entry)
if model.startswith('users.'):
auth_data.append(entry)
elif model.startswith('common.'):
common_data.append(entry)
elif model.startswith('plugin.'):
plugin_data.append(entry)
else:
load_data.append(entry)
all_data.append(entry)
else:
warning('WARNING: Invalid entry in data file')
error(
f'{"ERROR" if strict else "WARNING"}: Invalid entry in data file - missing "model" key'
)
print(entry)
if strict:
sys.exit(1)
# Check the metadata associated with the imported data
validate_import_metadata(c, metadata, strict=strict)
# Do not validate the 'apps' list yet - as the plugins have not yet been loaded
validate_import_metadata(c, metadata, strict=strict, apps=False)
# Write the auth file data
with open(authfile, 'w', encoding='utf-8') as f_out:
f_out.write(json.dumps(auth_data, indent=2))
# Load the temporary files in order
load_data('auth', auth_data)
load_data('common', common_data, app='common')
# Write the processed data to the tmp file
with open(datafile, 'w', encoding='utf-8') as f_out:
f_out.write(json.dumps(load_data, indent=2))
if not exclude_plugins:
load_data('plugins', plugin_data, app='plugin')
# A set of content types to exclude from the import process
excludes = content_excludes(allow_auth=False)
# Now that the plugins have been loaded, run database migrations again to ensure any new plugins have their database schema up to date
if not skip_migrations:
migrate(c)
# Import auth models first
info('Importing user auth data...')
cmd = f"loaddata '{authfile}'"
# Run validation again - ensure that the plugin apps have been loaded correctly
validate_import_metadata(c, metadata, strict=strict, apps=True)
if ignore_nonexistent:
cmd += ' --ignorenonexistent'
manage(c, cmd, pty=True)
# Import everything else next
info('Importing database records...')
cmd = f"loaddata '{datafile}' -i {excludes}"
if ignore_nonexistent:
cmd += ' --ignorenonexistent'
manage(c, cmd, pty=True)
if not retain_temp:
info('Removing temporary files')
os.remove(datafile)
os.remove(authfile)
load_data('remaining', all_data, excludes=content_excludes(allow_auth=False))
success('Data import completed')
@ -1664,9 +1678,7 @@ def setup_test(
# Load data
info('Loading database records ...')
import_records(
c, filename=template_dir.joinpath('inventree_data.json'), clear=True, force=True
)
import_records(c, filename=template_dir.joinpath('inventree_data.json'), clear=True)
# Copy media files
src = template_dir.joinpath('media')