mirror of
https://github.com/inventree/InvenTree.git
synced 2025-12-20 13:50:36 -06:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f23d405392 | ||
|
|
3fe04747d7 | ||
|
|
8ff4eddeb9 | ||
|
|
de2edc4ed6 | ||
|
|
343f63c6ba | ||
|
|
2bccbffe56 | ||
|
|
5af0e50b79 | ||
|
|
0ae9cdd39f | ||
|
|
7babef098a | ||
|
|
fab846e3cc | ||
|
|
d485c6796b | ||
|
|
5c94366bb5 | ||
|
|
cebad3d142 | ||
|
|
3659bbe389 | ||
|
|
933a5a5595 | ||
|
|
6c0f6e38d0 | ||
|
|
8c9a438e59 | ||
|
|
6e37f0cd8b | ||
|
|
1c6d25ce33 | ||
|
|
86111ad9b9 | ||
|
|
524e6ddf79 | ||
|
|
83be1b8a0f | ||
|
|
974c2737af | ||
|
|
0a0da7b65b | ||
|
|
b12bd3bb4b | ||
|
|
83be3cfa71 | ||
|
|
fda47ff6ee | ||
|
|
69676f308b | ||
|
|
178e3313f9 | ||
|
|
b0353fafbf |
49
docs/docs/start/accounts.md
Normal file
49
docs/docs/start/accounts.md
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: Account Management
|
||||
---
|
||||
|
||||
## User Accounts
|
||||
|
||||
By default, InvenTree does not ship with any user accounts. Configuring user accounts is the first step to login to the InvenTree server.
|
||||
|
||||
### Administrator Account
|
||||
|
||||
You can configure InvenTree to create an administrator account on the first run. This account will have full *superuser* access to the InvenTree server.
|
||||
|
||||
This account is created when you first run the InvenTree server instance. The username / password for this account can be configured in the configuration file, or environment variables.
|
||||
|
||||
!!! info "More Information"
|
||||
For more information on configuring the administrator account, refer to the [configuration documentation](./config.md#administrator-account).
|
||||
|
||||
### Create Superuser
|
||||
|
||||
Another way to create an administrator account is to use the `superuser` command. This will create a new superuser account with the specified username and password.
|
||||
|
||||
```bash
|
||||
invoke superuser
|
||||
```
|
||||
|
||||
Or, if you are running InvenTree in a Docker container:
|
||||
|
||||
```bash
|
||||
docker exec -rm -it inventree-server invoke superuser
|
||||
```
|
||||
|
||||
### User Management
|
||||
|
||||
Once you have created an administrator account, you can create and manage additional user accounts from the InvenTree web interface.
|
||||
|
||||
## Password Management
|
||||
|
||||
### Reset Password via Command Line
|
||||
|
||||
If a password has been lost, and other backup options (such as email recovery) are unavailable, the system administrator can reset the password for a user account from the command line.
|
||||
|
||||
Log into the machine running the InvenTree server, and run the following command (from the top-level source directory):
|
||||
|
||||
```bash
|
||||
cd src/backend/InvenTree
|
||||
python ./manage.py changepassword <username>
|
||||
```
|
||||
|
||||
The system will prompt you to enter a new password for the specified user account.
|
||||
@@ -37,8 +37,11 @@ The following files required for this setup are provided with the InvenTree sour
|
||||
|
||||
Download these files to a directory on your local machine.
|
||||
|
||||
!!! warning "File Extensions"
|
||||
If your computer adds *.txt* extensions to any of the downloaded files, rename the file and remove the added extension before continuing!
|
||||
|
||||
!!! success "Working Directory"
|
||||
This tutorial assumes you are working from a direction where all of these files are located.
|
||||
This tutorial assumes you are working from a directory where all of these files are located.
|
||||
|
||||
!!! tip "No Source Required"
|
||||
For a production setup you do not need the InvenTree source code. Simply download the three required files from the links above!
|
||||
|
||||
@@ -97,6 +97,7 @@ nav:
|
||||
- Production: start/bare_prod.md
|
||||
- Development: start/bare_dev.md
|
||||
- Serving Files: start/serving_files.md
|
||||
- User Accounts: start/accounts.md
|
||||
- Data Backup: start/backup.md
|
||||
- Migrating Data: start/migrate.md
|
||||
- Advanced Topics: start/advanced.md
|
||||
|
||||
@@ -383,11 +383,26 @@ class BulkDeleteMixin:
|
||||
|
||||
# Filter by provided item ID values
|
||||
if items:
|
||||
queryset = queryset.filter(id__in=items)
|
||||
try:
|
||||
queryset = queryset.filter(id__in=items)
|
||||
except Exception:
|
||||
raise ValidationError({
|
||||
'non_field_errors': _('Invalid items list provided')
|
||||
})
|
||||
|
||||
# Filter by provided filters
|
||||
if filters:
|
||||
queryset = queryset.filter(**filters)
|
||||
try:
|
||||
queryset = queryset.filter(**filters)
|
||||
except Exception:
|
||||
raise ValidationError({
|
||||
'non_field_errors': _('Invalid filters provided')
|
||||
})
|
||||
|
||||
if queryset.count() == 0:
|
||||
raise ValidationError({
|
||||
'non_field_errors': _('No items found to delete')
|
||||
})
|
||||
|
||||
# Run a final validation step (should raise an error if the deletion should not proceed)
|
||||
self.validate_delete(queryset, request)
|
||||
|
||||
@@ -40,9 +40,14 @@ class InvenTreeConfig(AppConfig):
|
||||
- Adding users set in the current environment
|
||||
"""
|
||||
# skip loading if plugin registry is not loaded or we run in a background thread
|
||||
|
||||
if not InvenTree.ready.isPluginRegistryLoaded():
|
||||
return
|
||||
|
||||
# Skip if not in worker or main thread
|
||||
if (
|
||||
not InvenTree.ready.isPluginRegistryLoaded()
|
||||
or not InvenTree.ready.isInMainThread()
|
||||
not InvenTree.ready.isInMainThread()
|
||||
and not InvenTree.ready.isInWorkerThread()
|
||||
):
|
||||
return
|
||||
|
||||
@@ -52,7 +57,6 @@ class InvenTreeConfig(AppConfig):
|
||||
|
||||
if InvenTree.ready.canAppAccessDatabase() or settings.TESTING_ENV:
|
||||
self.remove_obsolete_tasks()
|
||||
|
||||
self.collect_tasks()
|
||||
self.start_background_tasks()
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from django.core.files.storage import Storage, default_storage
|
||||
from django.http import StreamingHttpResponse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import bleach
|
||||
import pytz
|
||||
import regex
|
||||
from bleach import clean
|
||||
@@ -810,6 +811,65 @@ def remove_non_printable_characters(
|
||||
return cleaned
|
||||
|
||||
|
||||
def clean_markdown(value: str):
|
||||
"""Clean a markdown string.
|
||||
|
||||
This function will remove javascript and other potentially harmful content from the markdown string.
|
||||
"""
|
||||
import markdown
|
||||
|
||||
try:
|
||||
markdownify_settings = settings.MARKDOWNIFY['default']
|
||||
except (AttributeError, KeyError):
|
||||
markdownify_settings = {}
|
||||
|
||||
extensions = markdownify_settings.get('MARKDOWN_EXTENSIONS', [])
|
||||
extension_configs = markdownify_settings.get('MARKDOWN_EXTENSION_CONFIGS', {})
|
||||
|
||||
# Generate raw HTML from provided markdown (without sanitizing)
|
||||
# Note: The 'html' output_format is required to generate self closing tags, e.g. <tag> instead of <tag />
|
||||
html = markdown.markdown(
|
||||
value or '',
|
||||
extensions=extensions,
|
||||
extension_configs=extension_configs,
|
||||
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
|
||||
)
|
||||
whitelist_protocols = markdownify_settings.get(
|
||||
'WHITELIST_PROTOCOLS', bleach.sanitizer.ALLOWED_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,
|
||||
)
|
||||
|
||||
# Clean the HTML content (for comparison). This must be the same as the original content
|
||||
clean_html = cleaner.clean(html)
|
||||
|
||||
if html != clean_html:
|
||||
raise ValidationError(_('Data contains prohibited markdown content'))
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def hash_barcode(barcode_data):
|
||||
"""Calculate a 'unique' hash for a barcode string.
|
||||
|
||||
|
||||
@@ -6,7 +6,11 @@ from rest_framework import generics, mixins, status
|
||||
from rest_framework.response import Response
|
||||
|
||||
from InvenTree.fields import InvenTreeNotesField
|
||||
from InvenTree.helpers import remove_non_printable_characters, strip_html_tags
|
||||
from InvenTree.helpers import (
|
||||
clean_markdown,
|
||||
remove_non_printable_characters,
|
||||
strip_html_tags,
|
||||
)
|
||||
|
||||
|
||||
class CleanMixin:
|
||||
@@ -57,6 +61,7 @@ class CleanMixin:
|
||||
|
||||
# By default, newline characters are removed
|
||||
remove_newline = True
|
||||
is_markdown = False
|
||||
|
||||
try:
|
||||
if hasattr(self, 'serializer_class'):
|
||||
@@ -64,11 +69,12 @@ class CleanMixin:
|
||||
field = model._meta.get_field(field)
|
||||
|
||||
# The following field types allow newline characters
|
||||
allow_newline = [InvenTreeNotesField]
|
||||
allow_newline = [(InvenTreeNotesField, True)]
|
||||
|
||||
for field_type in allow_newline:
|
||||
if issubclass(type(field), field_type):
|
||||
if issubclass(type(field), field_type[0]):
|
||||
remove_newline = False
|
||||
is_markdown = field_type[1]
|
||||
break
|
||||
|
||||
except AttributeError:
|
||||
@@ -80,6 +86,9 @@ class CleanMixin:
|
||||
cleaned, remove_newline=remove_newline
|
||||
)
|
||||
|
||||
if is_markdown:
|
||||
cleaned = clean_markdown(cleaned)
|
||||
|
||||
return cleaned
|
||||
|
||||
def clean_data(self, data: dict) -> dict:
|
||||
|
||||
@@ -882,8 +882,8 @@ class RemoteImageMixin(metaclass=serializers.SerializerMetaclass):
|
||||
|
||||
try:
|
||||
self.remote_image_file = download_image_from_url(url)
|
||||
except Exception as exc:
|
||||
except Exception:
|
||||
self.remote_image_file = None
|
||||
raise ValidationError(str(exc))
|
||||
raise ValidationError(_('Failed to download image from remote URL'))
|
||||
|
||||
return url
|
||||
|
||||
@@ -33,7 +33,8 @@ from . import config, locales
|
||||
|
||||
checkMinPythonVersion()
|
||||
|
||||
INVENTREE_NEWS_URL = 'https://inventree.org/news/feed.atom'
|
||||
INVENTREE_BASE_URL = 'https://inventree.org'
|
||||
INVENTREE_NEWS_URL = f'{INVENTREE_BASE_URL}/news/feed.atom'
|
||||
|
||||
# Determine if we are running in "test" mode e.g. "manage.py test"
|
||||
TESTING = 'test' in sys.argv or 'TESTING' in os.environ
|
||||
@@ -1060,26 +1061,40 @@ if (
|
||||
sys.exit(-1)
|
||||
|
||||
COOKIE_MODE = (
|
||||
str(get_setting('INVENTREE_COOKIE_SAMESITE', 'cookie.samesite', 'None'))
|
||||
str(get_setting('INVENTREE_COOKIE_SAMESITE', 'cookie.samesite', 'False'))
|
||||
.lower()
|
||||
.strip()
|
||||
)
|
||||
|
||||
valid_cookie_modes = {'lax': 'Lax', 'strict': 'Strict', 'none': None, 'null': None}
|
||||
# Valid modes (as per the django settings documentation)
|
||||
valid_cookie_modes = ['lax', 'strict', 'none']
|
||||
|
||||
if COOKIE_MODE not in valid_cookie_modes.keys():
|
||||
logger.error('Invalid cookie samesite mode: %s', COOKIE_MODE)
|
||||
sys.exit(-1)
|
||||
|
||||
COOKIE_MODE = valid_cookie_modes[COOKIE_MODE.lower()]
|
||||
if not DEBUG and not TESTING and COOKIE_MODE in valid_cookie_modes:
|
||||
# Set the cookie mode (in production mode only)
|
||||
COOKIE_MODE = COOKIE_MODE.capitalize()
|
||||
else:
|
||||
# Default to False, as per the Django settings
|
||||
COOKIE_MODE = False
|
||||
|
||||
# Additional CSRF settings
|
||||
CSRF_HEADER_NAME = 'HTTP_X_CSRFTOKEN'
|
||||
CSRF_COOKIE_NAME = 'csrftoken'
|
||||
|
||||
CSRF_COOKIE_SAMESITE = COOKIE_MODE
|
||||
SESSION_COOKIE_SAMESITE = COOKIE_MODE
|
||||
SESSION_COOKIE_SECURE = get_boolean_setting(
|
||||
'INVENTREE_SESSION_COOKIE_SECURE', 'cookie.secure', False
|
||||
|
||||
"""Set the SESSION_COOKIE_SECURE value based on the following rules:
|
||||
- False if the server is running in DEBUG mode
|
||||
- True if samesite cookie setting is set to 'None'
|
||||
- Otherwise, use the value specified in the configuration file (or env var)
|
||||
"""
|
||||
SESSION_COOKIE_SECURE = (
|
||||
False
|
||||
if DEBUG
|
||||
else (
|
||||
SESSION_COOKIE_SAMESITE == 'None'
|
||||
or get_boolean_setting('INVENTREE_SESSION_COOKIE_SECURE', 'cookie.secure', True)
|
||||
)
|
||||
)
|
||||
|
||||
USE_X_FORWARDED_HOST = get_boolean_setting(
|
||||
@@ -1231,23 +1246,29 @@ MARKDOWNIFY = {
|
||||
'abbr',
|
||||
'b',
|
||||
'blockquote',
|
||||
'code',
|
||||
'em',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'hr',
|
||||
'i',
|
||||
'img',
|
||||
'li',
|
||||
'ol',
|
||||
'p',
|
||||
'pre',
|
||||
's',
|
||||
'strong',
|
||||
'ul',
|
||||
'table',
|
||||
'thead',
|
||||
'tbody',
|
||||
'th',
|
||||
'tr',
|
||||
'td',
|
||||
'ul',
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from django_q.models import Success
|
||||
from django_q.status import Stat
|
||||
@@ -63,13 +62,13 @@ def check_system_health(**kwargs):
|
||||
|
||||
if not is_worker_running(**kwargs): # pragma: no cover
|
||||
result = False
|
||||
logger.warning(_('Background worker check failed'))
|
||||
logger.warning('Background worker check failed')
|
||||
|
||||
if not InvenTree.email.is_email_configured(): # pragma: no cover
|
||||
result = False
|
||||
logger.warning(_('Email backend not configured'))
|
||||
logger.warning('Email backend not configured')
|
||||
|
||||
if not result: # pragma: no cover
|
||||
logger.warning(_('InvenTree system health checks failed'))
|
||||
logger.warning('InvenTree system health checks failed')
|
||||
|
||||
return result
|
||||
|
||||
@@ -18,7 +18,7 @@ from django.conf import settings
|
||||
from .api_version import INVENTREE_API_TEXT, INVENTREE_API_VERSION
|
||||
|
||||
# InvenTree software version
|
||||
INVENTREE_SW_VERSION = '0.16.3'
|
||||
INVENTREE_SW_VERSION = '0.16.8'
|
||||
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
@@ -1502,12 +1502,19 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel):
|
||||
'quantity': _(f'Allocated quantity ({q}) must not exceed available stock quantity ({a})')
|
||||
})
|
||||
|
||||
# Allocated quantity cannot cause the stock item to be over-allocated
|
||||
# Ensure that we do not 'over allocate' a stock item
|
||||
available = decimal.Decimal(self.stock_item.quantity)
|
||||
allocated = decimal.Decimal(self.stock_item.allocation_count())
|
||||
quantity = decimal.Decimal(self.quantity)
|
||||
build_allocation_count = decimal.Decimal(self.stock_item.build_allocation_count(
|
||||
exclude_allocations={'pk': self.pk}
|
||||
))
|
||||
sales_allocation_count = decimal.Decimal(self.stock_item.sales_order_allocation_count())
|
||||
|
||||
if available - allocated + quantity < quantity:
|
||||
total_allocation = (
|
||||
build_allocation_count + sales_allocation_count + quantity
|
||||
)
|
||||
|
||||
if total_allocation > available:
|
||||
raise ValidationError({
|
||||
'quantity': _('Stock item is over-allocated')
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load generic %}
|
||||
{% load barcode %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block page_title %}
|
||||
@@ -277,7 +278,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
$('#show-qr-code').click(function() {
|
||||
showQRDialog(
|
||||
'{% trans "Build Order QR Code" escape %}',
|
||||
'{{ build.barcode }}'
|
||||
`{% clean_barcode build.barcode %}`
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -920,6 +920,65 @@ class BuildAllocationTest(BuildAPITest):
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
class BuildItemTest(BuildAPITest):
|
||||
"""Unit tests for build items.
|
||||
|
||||
For this test, we will be using Build ID=1;
|
||||
|
||||
- This points to Part 100 (see fixture data in part.yaml)
|
||||
- This Part already has a BOM with 4 items (see fixture data in bom.yaml)
|
||||
- There are no BomItem objects yet created for this build
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""Basic operation as part of test suite setup"""
|
||||
super().setUp()
|
||||
|
||||
self.assignRole('build.add')
|
||||
self.assignRole('build.change')
|
||||
|
||||
self.build = Build.objects.get(pk=1)
|
||||
|
||||
# Regenerate BuildLine objects
|
||||
self.build.create_build_line_items()
|
||||
|
||||
# Record number of build items which exist at the start of each test
|
||||
self.n = BuildItem.objects.count()
|
||||
|
||||
def test_update_overallocated(self):
|
||||
"""Test update of overallocated stock items."""
|
||||
|
||||
si = StockItem.objects.get(pk=2)
|
||||
|
||||
# Find line item
|
||||
line = self.build.build_lines.all().filter(bom_item__sub_part=si.part).first()
|
||||
|
||||
# Set initial stock item quantity
|
||||
si.quantity = 100
|
||||
si.save()
|
||||
|
||||
# Create build item
|
||||
bi = BuildItem(
|
||||
build_line=line,
|
||||
stock_item=si,
|
||||
quantity=100
|
||||
)
|
||||
bi.save()
|
||||
|
||||
# Reduce stock item quantity
|
||||
si.quantity = 50
|
||||
si.save()
|
||||
|
||||
# Reduce build item quantity
|
||||
url = reverse('api-build-item-detail', kwargs={'pk': bi.pk})
|
||||
|
||||
self.patch(
|
||||
url,
|
||||
{
|
||||
"quantity": 50,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
class BuildOverallocationTest(BuildAPITest):
|
||||
"""Unit tests for over allocation of stock items against a build order.
|
||||
|
||||
@@ -29,5 +29,5 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(set_default_currency),
|
||||
migrations.RunPython(set_default_currency, reverse_code=migrations.RunPython.noop),
|
||||
]
|
||||
|
||||
@@ -10,7 +10,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import common.models
|
||||
import InvenTree.helpers
|
||||
from InvenTree.ready import isImportingData
|
||||
from InvenTree.ready import isImportingData, isRebuildingData
|
||||
from plugin import registry
|
||||
from plugin.models import NotificationUserSetting, PluginConfig
|
||||
from users.models import Owner
|
||||
@@ -185,9 +185,20 @@ class MethodStorageClass:
|
||||
Is initialized on startup as one instance named `storage` in this file.
|
||||
"""
|
||||
|
||||
liste = None
|
||||
methods_list = None
|
||||
user_settings = {}
|
||||
|
||||
@property
|
||||
def methods(self):
|
||||
"""Return all available methods.
|
||||
|
||||
This is cached, and stored internally.
|
||||
"""
|
||||
if self.methods_list is None:
|
||||
self.collect()
|
||||
|
||||
return self.methods_list
|
||||
|
||||
def collect(self, selected_classes=None):
|
||||
"""Collect all classes in the environment that are notification methods.
|
||||
|
||||
@@ -196,7 +207,8 @@ class MethodStorageClass:
|
||||
Args:
|
||||
selected_classes (class, optional): References to the classes that should be registered. Defaults to None.
|
||||
"""
|
||||
logger.debug('Collecting notification methods')
|
||||
logger.debug('Collecting notification methods...')
|
||||
|
||||
current_method = (
|
||||
InvenTree.helpers.inheritors(NotificationMethod) - IGNORED_NOTIFICATION_CLS
|
||||
)
|
||||
@@ -219,8 +231,12 @@ class MethodStorageClass:
|
||||
item.plugin = plugin() if plugin else None
|
||||
filtered_list[ref] = item
|
||||
|
||||
storage.liste = list(filtered_list.values())
|
||||
logger.info('Found %s notification methods', len(storage.liste))
|
||||
storage.methods_list = list(filtered_list.values())
|
||||
|
||||
logger.info('Found %s notification methods', len(storage.methods_list))
|
||||
|
||||
for item in storage.methods_list:
|
||||
logger.debug(' - %s', str(item))
|
||||
|
||||
def get_usersettings(self, user) -> list:
|
||||
"""Returns all user settings for a specific user.
|
||||
@@ -234,7 +250,8 @@ class MethodStorageClass:
|
||||
list: All applicablae notification settings.
|
||||
"""
|
||||
methods = []
|
||||
for item in storage.liste:
|
||||
|
||||
for item in storage.methods:
|
||||
if item.USER_SETTING:
|
||||
new_key = f'NOTIFICATION_METHOD_{item.METHOD_NAME.upper()}'
|
||||
|
||||
@@ -250,6 +267,7 @@ class MethodStorageClass:
|
||||
'icon': getattr(item, 'METHOD_ICON', ''),
|
||||
'method': item.METHOD_NAME,
|
||||
})
|
||||
|
||||
return methods
|
||||
|
||||
|
||||
@@ -352,7 +370,7 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
|
||||
delivery_methods = kwargs.get('delivery_methods', None)
|
||||
|
||||
# Check if data is importing currently
|
||||
if isImportingData():
|
||||
if isImportingData() or isRebuildingData():
|
||||
return
|
||||
|
||||
# Resolve object reference
|
||||
@@ -422,7 +440,7 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
|
||||
|
||||
# Collect possible methods
|
||||
if delivery_methods is None:
|
||||
delivery_methods = storage.liste or []
|
||||
delivery_methods = storage.methods or []
|
||||
else:
|
||||
delivery_methods = delivery_methods - IGNORED_NOTIFICATION_CLS
|
||||
|
||||
@@ -439,7 +457,7 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
|
||||
# Set delivery flag
|
||||
common.models.NotificationEntry.notify(category, obj_ref_value)
|
||||
else:
|
||||
logger.debug("No possible users for notification '%s'", category)
|
||||
logger.info("No possible users for notification '%s'", category)
|
||||
|
||||
|
||||
def trigger_superuser_notification(plugin: PluginConfig, msg: str):
|
||||
|
||||
@@ -546,6 +546,10 @@ class AttachmentSerializer(InvenTreeModelSerializer):
|
||||
|
||||
model_type = self.validated_data.get('model_type', None)
|
||||
|
||||
# If the model type is not specified, attempt to infer it from the instance
|
||||
if model_type is None and self.instance:
|
||||
model_type = self.instance.model_type
|
||||
|
||||
# Ensure that the user has permission to attach files to the specified model
|
||||
user = self.context.get('request').user
|
||||
|
||||
|
||||
@@ -70,6 +70,10 @@ def update_news_feed():
|
||||
if entry.id in id_list:
|
||||
continue
|
||||
|
||||
# Enforce proper links for the entries
|
||||
if entry.link and str(entry.link).startswith('/'):
|
||||
entry.link = settings.INVENTREE_BASE_URL + str(entry.link)
|
||||
|
||||
# Create entry
|
||||
try:
|
||||
NewsFeedEntry.objects.create(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends "page_base.html" %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load barcode %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block page_title %}
|
||||
@@ -303,7 +304,7 @@ onPanelLoad('supplier-part-notes', function() {
|
||||
$("#show-qr-code").click(function() {
|
||||
showQRDialog(
|
||||
'{% trans "Supplier Part QR Code" escape %}',
|
||||
'{{ part.barcode }}'
|
||||
`{% clean_barcode part.barcode %}`
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -156,6 +156,52 @@ class CompanyTest(InvenTreeAPITestCase):
|
||||
len(self.get(url, data={'active': False}, expected_code=200).data), 1
|
||||
)
|
||||
|
||||
def test_company_notes(self):
|
||||
"""Test the markdown 'notes' field for the Company model."""
|
||||
pk = Company.objects.first().pk
|
||||
url = reverse('api-company-detail', kwargs={'pk': pk})
|
||||
|
||||
# Attempt to inject malicious markdown into the "notes" field
|
||||
xss = [
|
||||
'[Click me](javascript:alert(123))',
|
||||
')',
|
||||
')',
|
||||
]
|
||||
|
||||
for note in xss:
|
||||
response = self.patch(url, {'notes': note}, expected_code=400)
|
||||
|
||||
self.assertIn(
|
||||
'Data contains prohibited markdown content', str(response.data)
|
||||
)
|
||||
|
||||
# Tests with disallowed tags
|
||||
invalid_tags = [
|
||||
'<iframe src="javascript:alert(123)"></iframe>',
|
||||
'<canvas>A disallowed tag!</canvas>',
|
||||
]
|
||||
|
||||
for note in invalid_tags:
|
||||
response = self.patch(url, {'notes': note}, expected_code=400)
|
||||
|
||||
self.assertIn('Remove HTML tags from this value', str(response.data))
|
||||
|
||||
# The following markdown is safe, and should be accepted
|
||||
good = [
|
||||
'This is a **bold** statement',
|
||||
'This is a *italic* statement',
|
||||
'This is a [link](https://www.google.com)',
|
||||
'This is an ',
|
||||
'This is a `code` block',
|
||||
'This text has ~~strikethrough~~ formatting',
|
||||
'This text has a raw link - https://www.google.com - and should still pass the test',
|
||||
]
|
||||
|
||||
for note in good:
|
||||
response = self.patch(url, {'notes': note}, expected_code=200)
|
||||
|
||||
self.assertEqual(response.data['notes'], note)
|
||||
|
||||
|
||||
class ContactTest(InvenTreeAPITestCase):
|
||||
"""Tests for the Contact models."""
|
||||
|
||||
@@ -117,7 +117,7 @@ use_x_forwarded_port: false
|
||||
# Cookie settings
|
||||
cookie:
|
||||
secure: false
|
||||
samesite: none
|
||||
samesite: false
|
||||
|
||||
# Cross Origin Resource Sharing (CORS) settings (see https://github.com/adamchainz/django-cors-headers)
|
||||
cors:
|
||||
|
||||
@@ -2196,7 +2196,7 @@ class ReturnOrder(TotalPriceMixin, Order):
|
||||
# endregion
|
||||
|
||||
@transaction.atomic
|
||||
def receive_line_item(self, line, location, user, note=''):
|
||||
def receive_line_item(self, line, location, user, note='', **kwargs):
|
||||
"""Receive a line item against this ReturnOrder.
|
||||
|
||||
Rules:
|
||||
@@ -2222,7 +2222,7 @@ class ReturnOrder(TotalPriceMixin, Order):
|
||||
deltas['customer'] = stock_item.customer.pk
|
||||
|
||||
# Update the StockItem
|
||||
stock_item.status = StockStatus.QUARANTINED.value
|
||||
stock_item.status = kwargs.get('status', StockStatus.QUARANTINED.value)
|
||||
stock_item.location = location
|
||||
stock_item.customer = None
|
||||
stock_item.sales_order = None
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load barcode %}
|
||||
{% load inventree_extras %}
|
||||
{% load generic %}
|
||||
|
||||
@@ -312,7 +313,7 @@ $("#export-order").click(function() {
|
||||
$('#show-qr-code').click(function() {
|
||||
showQRDialog(
|
||||
'{% trans "Purchase Order QR Code" escape %}',
|
||||
'{{ order.barcode }}'
|
||||
`{% clean_barcode order.barcode %}`
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load barcode %}
|
||||
{% load inventree_extras %}
|
||||
{% load generic %}
|
||||
|
||||
@@ -257,7 +258,7 @@ $('#print-order-report').click(function() {
|
||||
$('#show-qr-code').click(function() {
|
||||
showQRDialog(
|
||||
'{% trans "Return Order QR Code" escape %}',
|
||||
'{{ order.barcode }}'
|
||||
`{% clean_barcode order.barcode %}`
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load barcode %}
|
||||
{% load inventree_extras %}
|
||||
{% load generic %}
|
||||
|
||||
@@ -319,7 +320,7 @@ $('#print-order-report').click(function() {
|
||||
$('#show-qr-code').click(function() {
|
||||
showQRDialog(
|
||||
'{% trans "Sales Order QR Code" escape %}',
|
||||
'{{ order.barcode }}'
|
||||
`{% clean_barcode order.barcode %}`
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -353,7 +353,8 @@ class LineItemPricing(PartPricing):
|
||||
try:
|
||||
part_id = self.request.POST.get('pk')
|
||||
part = Part.objects.get(id=part_id)
|
||||
except Part.DoesNotExist:
|
||||
except Exception:
|
||||
# Part not found, or invalid ID
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load barcode %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block sidebar %}
|
||||
@@ -451,7 +452,7 @@
|
||||
$("#show-qr-code").click(function() {
|
||||
showQRDialog(
|
||||
'{% trans "Part QR Code" escape %}',
|
||||
'{{ part.barcode }}',
|
||||
`{% clean_barcode part.barcode %}`
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ def notification_list(context, *args, **kwargs):
|
||||
'description': a.__doc__,
|
||||
'name': a.__name__,
|
||||
}
|
||||
for a in storage.liste
|
||||
for a in storage.methods
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -125,12 +125,14 @@ class ReportConfig(AppConfig):
|
||||
# Read the existing template file
|
||||
data = template_file.open('r').read()
|
||||
|
||||
logger.info("Creating new label template: '%s'", template['name'])
|
||||
|
||||
# Create a new entry
|
||||
report.models.LabelTemplate.objects.create(
|
||||
**template, template=ContentFile(data, os.path.basename(filename))
|
||||
)
|
||||
try:
|
||||
# Create a new entry
|
||||
report.models.LabelTemplate.objects.create(
|
||||
**template, template=ContentFile(data, os.path.basename(filename))
|
||||
)
|
||||
logger.info("Creating new label template: '%s'", template['name'])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def create_default_reports(self):
|
||||
"""Create default report templates."""
|
||||
@@ -212,9 +214,11 @@ class ReportConfig(AppConfig):
|
||||
# Read the existing template file
|
||||
data = template_file.open('r').read()
|
||||
|
||||
logger.info("Creating new report template: '%s'", template['name'])
|
||||
|
||||
# Create a new entry
|
||||
report.models.ReportTemplate.objects.create(
|
||||
**template, template=ContentFile(data, os.path.basename(filename))
|
||||
)
|
||||
try:
|
||||
report.models.ReportTemplate.objects.create(
|
||||
**template, template=ContentFile(data, os.path.basename(filename))
|
||||
)
|
||||
logger.info("Created new report template: '%s'", template['name'])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Template tags for rendering various barcodes."""
|
||||
|
||||
from django import template
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
import barcode as python_barcode
|
||||
import qrcode.constants as ECL
|
||||
@@ -26,6 +27,23 @@ def image_data(img, fmt='PNG'):
|
||||
return report.helpers.encode_image_base64(img, fmt)
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
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.
|
||||
- Used to render raw barcode data into the rendered HTML templates
|
||||
"""
|
||||
from InvenTree.helpers import strip_html_tags
|
||||
|
||||
cleaned_date = strip_html_tags(data)
|
||||
|
||||
# Remove back-tick character (prevent injection)
|
||||
cleaned_date = cleaned_date.replace('`', '')
|
||||
|
||||
return mark_safe(cleaned_date)
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def qrcode(data, **kwargs):
|
||||
"""Return a byte-encoded QR code image.
|
||||
|
||||
@@ -1161,10 +1161,12 @@ class StockItem(
|
||||
location=location,
|
||||
)
|
||||
|
||||
# Clear out allocation information for the stock item
|
||||
self.customer = None
|
||||
self.belongs_to = None
|
||||
self.sales_order = None
|
||||
self.location = location
|
||||
self.clearAllocations()
|
||||
|
||||
trigger_event('stockitem.returnedfromcustomer', id=self.id)
|
||||
|
||||
@@ -1189,9 +1191,17 @@ class StockItem(
|
||||
|
||||
return False
|
||||
|
||||
def build_allocation_count(self):
|
||||
"""Return the total quantity allocated to builds."""
|
||||
query = self.allocations.aggregate(q=Coalesce(Sum('quantity'), Decimal(0)))
|
||||
def build_allocation_count(self, **kwargs):
|
||||
"""Return the total quantity allocated to builds, with optional filters."""
|
||||
query = self.allocations.all()
|
||||
|
||||
if filter_allocations := kwargs.get('filter_allocations'):
|
||||
query = query.filter(**filter_allocations)
|
||||
|
||||
if exclude_allocations := kwargs.get('exclude_allocations'):
|
||||
query = query.exclude(**exclude_allocations)
|
||||
|
||||
query = query.aggregate(q=Coalesce(Sum('quantity'), Decimal(0)))
|
||||
|
||||
total = query['q']
|
||||
|
||||
@@ -1577,6 +1587,13 @@ class StockItem(
|
||||
# Remove the equivalent number of items
|
||||
self.take_stock(quantity, user, notes=notes)
|
||||
|
||||
# Rebuild the stock tree
|
||||
try:
|
||||
StockItem.objects.partial_rebuild(tree_id=self.tree_id)
|
||||
except Exception:
|
||||
logger.warning('Failed to rebuild stock tree during serializeStock')
|
||||
StockItem.objects.rebuild()
|
||||
|
||||
@transaction.atomic
|
||||
def copyHistoryFrom(self, other):
|
||||
"""Copy stock history from another StockItem."""
|
||||
@@ -1761,7 +1778,7 @@ class StockItem(
|
||||
|
||||
# Any "sales order allocations" for the other item must be assigned to this one
|
||||
for allocation in other.sales_order_allocations.all():
|
||||
allocation.stock_item = self()
|
||||
allocation.stock_item = self
|
||||
allocation.save()
|
||||
|
||||
# Prevent atomicity issues when we are merging our own "parent" part in
|
||||
@@ -1811,7 +1828,7 @@ class StockItem(
|
||||
for tree_id in tree_ids:
|
||||
StockItem.objects.partial_rebuild(tree_id=tree_id)
|
||||
except Exception:
|
||||
logger.warning('Rebuilding entire StockItem tree')
|
||||
logger.warning('Rebuilding entire StockItem tree during merge_stock_items')
|
||||
StockItem.objects.rebuild()
|
||||
|
||||
@transaction.atomic
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% load plugin_extras %}
|
||||
{% load inventree_extras %}
|
||||
{% load generic %}
|
||||
{% load barcode %}
|
||||
{% load i18n %}
|
||||
{% load l10n %}
|
||||
|
||||
@@ -534,7 +535,7 @@ $('#stock-edit-status').click(function () {
|
||||
$("#show-qr-code").click(function() {
|
||||
showQRDialog(
|
||||
'{% trans "Stock Item QR Code" escape %}',
|
||||
'{{ item.barcode }}',
|
||||
`{% clean_barcode item.barcode %}`
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends "stock/stock_app_base.html" %}
|
||||
{% load static %}
|
||||
{% load barcode %}
|
||||
{% load inventree_extras %}
|
||||
{% load plugin_extras %}
|
||||
{% load i18n %}
|
||||
@@ -391,7 +392,7 @@
|
||||
$('#show-qr-code').click(function() {
|
||||
showQRDialog(
|
||||
'{% trans "Stock Location QR Code" escape %}',
|
||||
'{{ location.barcode }}'
|
||||
`{% clean_barcode location.barcode %}`
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1713,7 +1713,7 @@ class StockTestResultTest(StockAPITestCase):
|
||||
# Now, let's delete all the newly created items with a single API request
|
||||
# However, we will provide incorrect filters
|
||||
response = self.delete(
|
||||
url, {'items': tests, 'filters': {'stock_item': 10}}, expected_code=204
|
||||
url, {'items': tests, 'filters': {'stock_item': 10}}, expected_code=400
|
||||
)
|
||||
|
||||
self.assertEqual(StockItemTestResult.objects.count(), n + 50)
|
||||
|
||||
@@ -165,6 +165,7 @@ function supplierPartFields(options={}) {
|
||||
icon: 'fa-box',
|
||||
},
|
||||
pack_quantity: {},
|
||||
active: {},
|
||||
};
|
||||
|
||||
if (options.part) {
|
||||
|
||||
@@ -92,7 +92,7 @@ export function PrintingActions({
|
||||
url: apiUrl(ApiEndpoints.label_print),
|
||||
title: t`Print Label`,
|
||||
fields: labelFields,
|
||||
timeout: (items.length + 1) * 1000,
|
||||
timeout: (items.length + 1) * 5000,
|
||||
onClose: () => {
|
||||
setPluginKey('');
|
||||
},
|
||||
@@ -121,7 +121,7 @@ export function PrintingActions({
|
||||
const reportModal = useCreateApiFormModal({
|
||||
title: t`Print Report`,
|
||||
url: apiUrl(ApiEndpoints.report_print),
|
||||
timeout: (items.length + 1) * 1000,
|
||||
timeout: (items.length + 1) * 5000,
|
||||
fields: {
|
||||
template: {
|
||||
filters: {
|
||||
|
||||
20
tasks.py
20
tasks.py
@@ -411,6 +411,11 @@ def backup(c, clean=False, path=None):
|
||||
cmd = '--noinput --compress -v 2'
|
||||
|
||||
if path:
|
||||
# Resolve the provided path
|
||||
path = Path(path)
|
||||
if not os.path.isabs(path):
|
||||
path = localDir().joinpath(path).resolve()
|
||||
|
||||
cmd += f' -O {path}'
|
||||
|
||||
if clean:
|
||||
@@ -442,6 +447,11 @@ def restore(
|
||||
base_cmd = '--noinput --uncompress -v 2'
|
||||
|
||||
if path:
|
||||
# Resolve the provided path
|
||||
path = Path(path)
|
||||
if not os.path.isabs(path):
|
||||
path = localDir().joinpath(path).resolve()
|
||||
|
||||
base_cmd += f' -I {path}'
|
||||
|
||||
if ignore_database:
|
||||
@@ -1418,11 +1428,11 @@ def docs_server(c, address='localhost:8080', compile_schema=False):
|
||||
def clear_generated(c):
|
||||
"""Clear generated files from `inv update`."""
|
||||
# pyc/pyo files
|
||||
run(c, 'find . -name "*.pyc" -exec rm -f {} +')
|
||||
run(c, 'find . -name "*.pyo" -exec rm -f {} +')
|
||||
run(c, 'find src -name "*.pyc" -exec rm -f {} +')
|
||||
run(c, 'find src -name "*.pyo" -exec rm -f {} +')
|
||||
# cache folders
|
||||
run(c, 'find . -name "__pycache__" -exec rm -rf {} +')
|
||||
run(c, 'find src -name "__pycache__" -exec rm -rf {} +')
|
||||
|
||||
# Generated translations
|
||||
run(c, 'find . -name "django.mo" -exec rm -f {} +')
|
||||
run(c, 'find . -name "messages.mo" -exec rm -f {} +')
|
||||
run(c, 'find src -name "django.mo" -exec rm -f {} +')
|
||||
run(c, 'find src -name "messages.mo" -exec rm -f {} +')
|
||||
|
||||
Reference in New Issue
Block a user