2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-22 23:00:54 +00:00

Merge branch 'master' into matmair/issue6281

This commit is contained in:
Matthias Mair
2025-01-02 09:25:47 +01:00
committed by GitHub
147 changed files with 59274 additions and 53019 deletions
.github/workflows
RELEASE.md
contrib
container
packager.io
docs/docs
src
backend
InvenTree
InvenTree
build
common
config_template.yaml
generic
locale
ar
LC_MESSAGES
bg
LC_MESSAGES
cs
LC_MESSAGES
da
LC_MESSAGES
de
LC_MESSAGES
el
LC_MESSAGES
en
LC_MESSAGES
es
LC_MESSAGES
es_MX
LC_MESSAGES
et
LC_MESSAGES
fa
LC_MESSAGES
fi
LC_MESSAGES
fr
LC_MESSAGES
he
LC_MESSAGES
hi
LC_MESSAGES
hu
LC_MESSAGES
id
LC_MESSAGES
it
LC_MESSAGES
ja
LC_MESSAGES
ko
LC_MESSAGES
lt
LC_MESSAGES
lv
LC_MESSAGES
nl
LC_MESSAGES
no
LC_MESSAGES
pl
LC_MESSAGES
pt
LC_MESSAGES
pt_BR
LC_MESSAGES
ro
LC_MESSAGES
ru
LC_MESSAGES
sk
LC_MESSAGES
sl
LC_MESSAGES
sr
LC_MESSAGES
sv
LC_MESSAGES
th
LC_MESSAGES
tr
LC_MESSAGES
uk
LC_MESSAGES
vi
LC_MESSAGES
zh_Hans
LC_MESSAGES
zh_Hant
LC_MESSAGES
order
script
stock
frontend
tasks.py

@ -119,10 +119,10 @@ jobs:
- name: Run Unit Tests
run: |
echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> contrib/container/docker.dev.env
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run --rm inventree-dev-server invoke dev.test --disable-pty
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run --rm inventree-dev-server invoke dev.test --disable-pty --translations
- name: Run Migration Tests
run: |
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run --rm inventree-dev-server invoke dev.test --migrations
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run --rm inventree-dev-server invoke dev.test --migrations --translations
- name: Clean up test folder
run: |
rm -rf InvenTree/_testfolder

@ -305,7 +305,7 @@ jobs:
- name: Check Migration Files
run: python3 .github/scripts/check_migration_files.py
- name: Coverage Tests
run: invoke dev.test --coverage
run: invoke dev.test --coverage --translations
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # pin@v5.1.2
if: always()
@ -357,7 +357,7 @@ jobs:
dev-install: true
update: true
- name: Run Tests
run: invoke dev.test
run: invoke dev.test --translations
- name: Data Export Test
uses: ./.github/actions/migration
@ -404,7 +404,7 @@ jobs:
dev-install: true
update: true
- name: Run Tests
run: invoke dev.test
run: invoke dev.test --translations
- name: Data Export Test
uses: ./.github/actions/migration
@ -446,7 +446,7 @@ jobs:
dev-install: true
update: true
- name: Run Tests
run: invoke dev.test --migrations --report --coverage
run: invoke dev.test --migrations --report --coverage --translations
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # pin@v5.1.2
if: always()

@ -1,23 +0,0 @@
## Release Checklist
Checklist of steps to perform at each code release
### Update Version String
Update `INVENTREE_SW_VERSION` in [version.py](https://github.com/inventree/InvenTree/blob/master/src/backend/InvenTree/InvenTree/version.py)
### Increment API Version
If the API has changed, ensure that the API version number is incremented.
### Translation Files
Merge the crowdin translation updates into master branch
### Python Library Release
Create new release for the [InvenTree python library](https://github.com/inventree/inventree-python)
## App Release
Create new versioned release for the InvenTree mobile app.

@ -1,48 +1,53 @@
# InvenTree environment variables for docker compose deployment
# For a full list of the available configuration options, refer to the InvenTree documentation:
# https://docs.inventree.org/en/stable/start/config/
# Specify the name of the docker-compose project
COMPOSE_PROJECT_NAME=inventree
# InvenTree version tag (e.g. 'stable' / 'latest' / 'x.x.x')
INVENTREE_TAG=stable
# InvenTree server URL - update this to match your host
INVENTREE_SITE_URL="http://inventree.localhost"
# Specify the location of the external data volume
# By default, placed in local directory 'inventree-data'
INVENTREE_EXT_VOLUME=./inventree-data
# Ensure debug is false for a production setup
INVENTREE_DEBUG=False
INVENTREE_LOG_LEVEL=WARNING
# InvenTree admin account details
# Un-comment (and complete) these lines to auto-create an admin acount
#INVENTREE_ADMIN_USER=
#INVENTREE_ADMIN_PASSWORD=
#INVENTREE_ADMIN_EMAIL=
# Database configuration options
INVENTREE_DB_ENGINE=postgresql
INVENTREE_DB_NAME=inventree
INVENTREE_DB_HOST=inventree-db
INVENTREE_DB_PORT=5432
# Database credentials - These should be changed from the default values!
INVENTREE_DB_USER=pguser
INVENTREE_DB_PASSWORD=pgpassword
# Redis cache setup
# Refer to settings.py for other cache options
INVENTREE_CACHE_ENABLED=True
INVENTREE_CACHE_HOST=inventree-cache
INVENTREE_CACHE_PORT=6379
# Options for gunicorn server
INVENTREE_GUNICORN_TIMEOUT=90
# Enable custom plugins?
INVENTREE_PLUGINS_ENABLED=True
# Run migrations automatically?
INVENTREE_AUTO_UPDATE=True
# Image tag that should be used
INVENTREE_TAG=stable
# InvenTree superuser account details
# Un-comment (and complete) these lines to auto-create an admin acount
#INVENTREE_ADMIN_USER=
#INVENTREE_ADMIN_PASSWORD=
#INVENTREE_ADMIN_EMAIL=
# Site URL - update this to match your host
INVENTREE_SITE_URL="http://inventree.localhost"
# Database configuration options
# DO NOT CHANGE THESE SETTINGS (unless you really know what you are doing)
INVENTREE_DB_ENGINE=postgresql
INVENTREE_DB_NAME=inventree
INVENTREE_DB_HOST=inventree-db
INVENTREE_DB_PORT=5432
COMPOSE_PROJECT_NAME=inventree
# Database credentials - These should be changed from the default values!
# Note: These are *NOT* the InvenTree server login credentials,
# they are the credentials for the PostgreSQL database
INVENTREE_DB_USER=pguser
INVENTREE_DB_PASSWORD=pgpassword
# Redis cache setup
# Refer to the documentation for other cache options
INVENTREE_CACHE_ENABLED=True
INVENTREE_CACHE_HOST=inventree-cache
INVENTREE_CACHE_PORT=6379
# Options for gunicorn server
INVENTREE_GUNICORN_TIMEOUT=90

@ -4,14 +4,18 @@
# - INVENTREE_SERVER: The internal URL of the InvenTree container (default: http://inventree-server:8000)
#
# Note that while this file is a good starting point, it may need to be modified to suit your specific requirements
#
# Ref to the Caddyfile documentation: https://caddyserver.com/docs/caddyfile
# Logging configuration for Caddy
(log_common) {
log {
output file /var/log/caddy/{args[0]}.access.log
}
}
# CORS headers control (used for static and media files)
(cors-headers) {
header Allow GET,HEAD,OPTIONS
header Access-Control-Allow-Origin *
@ -25,8 +29,10 @@
}
}
# Change the host to your domain (this will serve at inventree.localhost)
{$INVENTREE_SITE_URL:inventree.localhost} {
# The default server address is configured in the .env file
# If not specified, the default address is used - http://inventree.localhost
# If you need to listen on multiple addresses, or use a different port, you can modify this section directly
{$INVENTREE_SITE_URL:http://inventree.localhost} {
import log_common inventree
encode gzip
@ -35,6 +41,7 @@
max_size 100MB
}
# Handle static request files
handle_path /static/* {
import cors-headers static
@ -42,18 +49,31 @@
file_server
}
# Handle media request files
handle_path /media/* {
import cors-headers media
root * /var/www/media
file_server
# Force download of media files (for security)
# Comment out this line if you do not want to force download
header Content-Disposition attachment
# Authentication is handled by the forward_auth directive
# This is required to ensure that media files are only accessible to authenticated users
forward_auth {$INVENTREE_SERVER:"http://inventree-server:8000"} {
uri /auth/
}
}
reverse_proxy {$INVENTREE_SERVER:"http://inventree-server:8000"}
# All other requests are proxied to the InvenTree server
reverse_proxy {$INVENTREE_SERVER:"http://inventree-server:8000"} {
# If you are running behind another proxy, you may need to specify 'trusted_proxies'
trusted_proxies {
# enter your trusted proxy IP addresses here
}
}
}

@ -292,14 +292,15 @@ function stop_inventree() {
}
function update_or_install() {
set -e
# Set permissions so app user can write there
chown ${APP_USER}:${APP_GROUP} ${APP_HOME} -R
# Run update as app user
echo "# POI12| Updating InvenTree"
sudo -u ${APP_USER} --preserve-env=$SETUP_ENVS bash -c "cd ${APP_HOME} && pip install uv wheel"
sudo -u ${APP_USER} --preserve-env=$SETUP_ENVS bash -c "cd ${APP_HOME} && invoke update --uv | sed -e 's/^/# POI12| u | /;'"
sudo -u ${APP_USER} --preserve-env=$SETUP_ENVS bash -c "cd ${APP_HOME} && pip install wheel"
sudo -u ${APP_USER} --preserve-env=$SETUP_ENVS bash -c "cd ${APP_HOME} && invoke update | sed -e 's/^/# POI12| u | /;'"
# Make sure permissions are correct again
echo "# POI12| Set permissions for data dir and media: ${DATA_DIR}"

@ -58,11 +58,18 @@ If you see this error, it means that the `INVENTREE_SITE_URL` environment variab
If you have successfully started the InvenTree server, but are experiencing issues logging in, it may be due to the security interactions between your web browser and the server. While the default configuration should work for most users, if you do experience login issues, ensure that your [server access settings](./start/config.md#server-access) are correctly configured.
### Session Cookies
The [0.17.0 release](https://github.com/inventree/InvenTree/releases/tag/0.17.0) included [a change to the way that session cookies were handled](https://github.com/inventree/InvenTree/pull/8269). This change may cause login issues for existing InvenTree installs which are upgraded from an older version. System administrators should refer to the [server access settings](./start/config.md#server-access) and ensure that the following settings are correctly configured:
- **INVENTREE_SESSION_COOKIE_SECURE**: `False`
- **INVENTREE_COOKIE_SAMESITE**: `False`
## Update Issues
Sometimes, users may encounter unexpected error messages when updating their InvenTree installation to a newer version.
The most common problem here is that the correct sequenct of steps has not been followed:
The most common problem here is that the correct sequence of steps has not been followed:
1. Ensure that the InvenTree web server and background worker processes are *halted*
1. Update the InvenTree software (e.g. using git or docker, depending on installation method)

@ -71,6 +71,8 @@ The following basic options are available:
The *INVENTREE_SITE_URL* option defines the base URL for the InvenTree server. This is a critical setting, and it is required for correct operation of the server. If not specified, the server will attempt to determine the site URL automatically - but this may not always be correct!
The site URL is the URL that users will use to access the InvenTree server. For example, if the server is accessible at `https://inventree.example.com`, the site URL should be set to `https://inventree.example.com`. Note that this is not necessarily the same as the internal URL that the server is running on - the internal URL will depend entirely on your server configuration and may be obscured by a reverse proxy or other such setup.
### Timezone
By default, the InvenTree server is configured to use the UTC timezone. This can be adjusted to your desired local timezone. You can refer to [Wikipedia](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for a list of available timezones. Use the values specified in the *TZ Identifier* column in the linked page.
@ -139,6 +141,7 @@ Depending on how your InvenTree installation is configured, you will need to pay
| INVENTREE_CORS_ALLOW_CREDENTIALS | cors.allow_credentials | Allow cookies in cross-site requests | `True` |
| INVENTREE_USE_X_FORWARDED_HOST | use_x_forwarded_host | Use forwarded host header | `False` |
| INVENTREE_USE_X_FORWARDED_PORT | use_x_forwarded_port | Use forwarded port header | `False` |
| INVENTREE_USE_X_FORWARDED_PROTO | use_x_forwarded_proto | Use forwarded protocol header | `False` |
| INVENTREE_SESSION_COOKIE_SECURE | cookie.secure | Enforce secure session cookies | `False` |
| INVENTREE_COOKIE_SAMESITE | cookie.samesite | Session cookie mode. Must be one of `Strict | Lax | None | False`. Refer to the [mozilla developer docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie) and the [django documentation]({% include "django.html" %}/ref/settings/#std-setting-SESSION_COOKIE_SAMESITE) for more information. | False |
@ -153,13 +156,41 @@ Note that in [debug mode](./intro.md#debug-mode), some of the above settings are
| `INVENTREE_COOKIE_SAMESITE` | `False` | Disable all same-site cookie checks in debug mode |
| `INVENTREE_SESSION_COOKIE_SECURE` | `False` | Disable secure session cookies in debug mode (allow non-https cookies) |
### INVENTREE_COOKIE_SAMESITE vs INVENTREE_SESSION_COOKIE_SECURE
### Cookie Settings
Note that if you set the `INVENTREE_COOKIE_SAMESITE` to `None`, then `INVENTREE_SESSION_COOKIE_SECURE` is automatically set to `True` to ensure that the session cookie is secure! This means that the session cookie will only be sent over secure (https) connections.
### Proxy Settings
### Proxy Considerations
If you are running InvenTree behind a proxy, or forwarded HTTPS connections, you will need to ensure that the InvenTree server is configured to listen on the correct host and port. You will likely have to adjust the `INVENTREE_ALLOWED_HOSTS` setting to ensure that the server will accept requests from the proxy.
Additionally, you may need to configure the following header to ensure that the InvenTree server is watching for information forwarded by the proxy:
**X-Forwarded-Host**
By default, InvenTree *will not* look at the [X-Forwarded-Host](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Host) header.
If you are running InvenTree behind a proxy which obscures the upstream host information, you will need to ensure that the `INVENTREE_USE_X_FORWARDED_HOST` setting is enabled. This will ensure that the InvenTree server uses the forwarded host header for processing requests.
You can also refer to the [Django documentation]({% include "django.html" %}/ref/settings/#secure-proxy-ssl-header) for more information on this header.
**X-Forwarded-Port**
InvenTree provides support for the `X-Forwarded-Port` header, which can be used to determine if the incoming request is using a forwarded port. If you are running InvenTree behind a proxy which forwards port information, you should ensure that the `INVENTREE_USE_X_FORWARDED_PORT` setting is enabled.
Note: This header is overridden by the `X-Forwarded-Host` header.
You can also refer to the [Django documentation]({% include "django.html" %}/ref/settings/#use-x-forwarded-port) for more information on this header.
**X-Forwarded-Proto**
InvenTree provides support for the [X-Forwarded-Proto](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto) header, which can be used to determine if the incoming request is using HTTPS, even if the server is running behind a proxy which forwards SSL connections. If you are running InvenTree behind a proxy which forwards SSL connections, you should ensure that the `INVENTREE_USE_X_FORWARDED_PROTO` setting is enabled.
You can also refer to the [Django documentation]({% include "django.html" %}/ref/settings/#use-x-forwarded-host) for more information on this header.
Proxy configuration can be complex, and any configuration beyond the basic setup is outside the scope of this documentation. You should refer to the documentation for the specific proxy server you are using.
Refer to the [proxy server documentation](./processes.md#proxy-server) for more information.
If you are running InvenTree behind another proxy, you will need to ensure that the InvenTree server is configured to listen on the correct host and port. You will likely have to adjust the `INVENTREE_ALLOWED_HOSTS` setting to ensure that the server will accept requests from the proxy.
## Admin Site
@ -195,6 +226,9 @@ You can either specify the password directly using `INVENTREE_ADMIN_PASSWORD`, o
InvenTree requires a secret key for providing cryptographic signing - this should be a secret (and unpredictable) value.
!!! info "Auto-Generated Key"
If none of the following options are specified, InvenTree will automatically generate a secret key file (stored in `secret_key.txt`) on first run.
The secret key can be provided in multiple ways, with the following (descending) priorities:
**Pass Secret Key via Environment Variable**

@ -46,7 +46,6 @@ invoke update
This step ensures that the required database tables exist, and are at the correct schema version, which must be the case before data can be imported.
### Import Data
The new database should now be correctly initialized with the correct table structures required to import the data. Run the following command to load the databased dump file into the new database.
@ -64,6 +63,18 @@ invoke import-records -c -f data.json
!!! warning "Character Encoding"
If the character encoding of the data file does not exactly match the target database, the import operation may not succeed. In this case, some manual editing of the database JSON file may be required.
### Copy Media Files
Any media files (images, documents, etc) that were stored in the original database must be copied to the new database. In a typical InvenTree installation, these files are stored in the `media` subdirectory of the InvenTree data location.
Copy the entire directory tree from the original InvenTree installation to the new InvenTree installation.
!!! warning "File Ownership"
Ensure that the file ownership and permissions are correctly set on the copied files. The InvenTree server process **must** have read / write access to these files. If not, the server will not be able to serve the media files correctly, and the user interface may not function as expected.
!!! warning "Directory Structure"
The expected locations of each file is stored in the database, and if the file paths are not correct, the media files will not be displayed correctly in the user interface. Thus, it is important that the files are transferred across to the new installation in the same directory structure.
## Migrating Data to Newer Version
If you are updating from an older version of InvenTree to a newer version, the migration steps outlined above *do not apply*.

@ -44,6 +44,12 @@ Further, it provides an authentication endpoint for accessing files in the `/sta
Finally, it provides a [Let's Encrypt](https://letsencrypt.org/) endpoint for automatic SSL certificate generation and renewal.
### Proxy Functionality
#### API and Web Requests
All API and web requests are reverse-proxied to the InvenTree django server. This allows the InvenTree web server to be accessed via a standard HTTP/HTTPS port, and allows the proxy server to handle SSL termination.
#### Static Files
Static files can be served without any need for authentication. In fact, they must be accessible *without* authentication, otherwise the unauthenticated views (such as the login screen) will not function correctly.
@ -52,15 +58,34 @@ Static files can be served without any need for authentication. In fact, they mu
It is highly recommended that the *media* files are served behind an authentication layer. This is because the media files are user-uploaded, and may contain sensitive information. Most modern web servers provide a way to serve files behind an authentication layer.
#### Example Configuration
### Proxy Configuration
The [docker production example](./docker.md) provides an example using [Caddy](https://caddyserver.com) to serve *static* and *media* files, and redirecting other requests to the InvenTree web server itself.
We provide some *sample* configuration files for getting your proxy server off the ground. The exact setup and configuration of your proxy server will depend on your specific requirements, and the software you choose to use. You may be integrating InvenTree with an existing web server, and the configuration may be different to the provided examples.
Caddy is a modern web server which is easy to configure and provides a number of useful features, including automatic SSL certificate generation.
#### Example Configurations
#### Alternatives to Caddy
**Caddy**
An alternative is to run nginx as the reverse proxy. A sample configuration file is provided in the `./contrib/container/` source directory.
The [docker production example](./docker.md) provides an example using [Caddy](https://caddyserver.com) to serve *static* and *media* files, and redirecting other requests to the InvenTree web server itself. Caddy is a modern web server which is easy to configure and provides a number of useful features, including automatic SSL certificate generation.
You can find the sample Caddy configuration [here]({{ sourcefile("contrib/container/Caddyfile") }}).
**Nginx**
An alternative is to run nginx as the reverse proxy. A sample configuration file is provided [here]({{ sourcefile("contrib/container/nginx.conf") }}).
#### Extending the Proxy Configuration
You may wish to extend the proxy configuration to include additional features, based on your particular requirements. Some examples of where additional configuration may be required include:
- **Upstream Proxy**: You may be running the InvenTree server behind another proxy server, and need to configure the proxy server to forward requests to the upstream proxy.
- **Authentication**: You may wish to add an authentication layer to the proxy server, to restrict access to the InvenTree web interface.
- **SSL Termination**: You may wish to terminate SSL connections at the proxy server, and forward unencrypted traffic to the InvenTree web server.
- **Load Balancing**: You may wish to run multiple instances of the InvenTree web server, and use the proxy server to load balance between them.
- **Custom Error Pages**: You may wish to provide custom error pages for certain HTTP status codes.
!!! warning "No Support"
We do not provide support for configuring your proxy server. The configuration of the proxy server is outside the scope of this documentation. If you require assistance with configuring your proxy server, please refer to the documentation for the specific software you are using.
#### Integrating with Existing Proxy

@ -1,7 +1,7 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 296
INVENTREE_API_VERSION = 297
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
@ -11,6 +11,9 @@ v296 - 2024-12-22 : https://github.com/inventree/InvenTree/pull/6293
- Removes a considerable amount of old auth endpoints
- Introduces allauth based REST API
v297 - 2024-12-29 - https://github.com/inventree/InvenTree/pull/8438
- Adjustments to the CustomUserState API endpoints and serializers
v296 - 2024-12-25 : https://github.com/inventree/InvenTree/pull/8732
- Adjust default "part_detail" behaviour for StockItem API endpoints

@ -419,21 +419,10 @@ def get_frontend_settings(debug=True):
# Set the base URL
if 'base_url' not in settings:
base_url = get_setting('INVENTREE_PUI_URL_BASE', 'pui_url_base', '')
if base_url:
warnings.warn(
"The 'INVENTREE_PUI_URL_BASE' key is deprecated. Please use 'INVENTREE_FRONTEND_URL_BASE' instead",
DeprecationWarning,
stacklevel=2,
)
else:
base_url = get_setting(
settings['base_url'] = get_setting(
'INVENTREE_FRONTEND_URL_BASE', 'frontend_url_base', 'platform'
)
settings['base_url'] = base_url
# Set the server list
settings['server_list'] = settings.get('server_list', [])

@ -1032,6 +1032,12 @@ if SITE_URL:
print(f"Invalid SITE_URL value: '{SITE_URL}'. InvenTree server cannot start.")
sys.exit(-1)
else:
logger.warning('No SITE_URL specified. Some features may not work correctly')
logger.warning(
'Specify a SITE_URL in the configuration file or via an environment variable'
)
# Enable or disable multi-site framework
SITE_MULTI = get_boolean_setting('INVENTREE_SITE_MULTI', 'site_multi', False)
@ -1152,6 +1158,18 @@ SESSION_COOKIE_SECURE = (
)
)
# Ref: https://docs.djangoproject.com/en/4.2/ref/settings/#std-setting-SECURE_PROXY_SSL_HEADER
if ssl_header := get_boolean_setting(
'INVENTREE_USE_X_FORWARDED_PROTO', 'use_x_forwarded_proto', False
):
# The default header name is 'HTTP_X_FORWARDED_PROTO', but can be adjusted
ssl_header_name = get_setting(
'INVENTREE_X_FORWARDED_PROTO_NAME',
'x_forwarded_proto_name',
'HTTP_X_FORWARDED_PROTO',
)
SECURE_PROXY_SSL_HEADER = (ssl_header_name, 'https')
USE_X_FORWARDED_HOST = get_boolean_setting(
'INVENTREE_USE_X_FORWARDED_HOST',
config_key='use_x_forwarded_host',
@ -1368,7 +1386,7 @@ CUSTOMIZE = get_setting(
# Load settings for the frontend interface
FRONTEND_SETTINGS = config.get_frontend_settings(debug=DEBUG)
FRONTEND_URL_BASE = FRONTEND_SETTINGS.get('base_url', 'platform')
FRONTEND_URL_BASE = FRONTEND_SETTINGS['base_url']
if DEBUG:
logger.info('InvenTree running with DEBUG enabled')

@ -34,7 +34,17 @@ class BuildFilter(rest_filters.FilterSet):
model = Build
fields = ['sales_order']
status = rest_filters.NumberFilter(label='Status')
status = rest_filters.NumberFilter(label=_('Order Status'), method='filter_status')
def filter_status(self, queryset, name, value):
"""Filter by integer status code.
Note: Also account for the possibility of a custom status code
"""
q1 = Q(status=value, status_custom_key__isnull=True)
q2 = Q(status_custom_key=value)
return queryset.filter(q1 | q2).distinct()
active = rest_filters.BooleanFilter(label='Build is active', method='filter_active')

@ -4,6 +4,7 @@ import django.core.validators
from django.db import migrations
import generic.states.fields
import generic.states.validators
import InvenTree.status_codes
@ -23,6 +24,11 @@ class Migration(migrations.Migration):
help_text="Additional status information for this item",
null=True,
verbose_name="Custom status key",
validators=[
generic.states.validators.CustomStatusCodeValidator(
status_class=InvenTree.status_codes.BuildStatus
),
]
),
),
migrations.AlterField(
@ -32,7 +38,12 @@ class Migration(migrations.Migration):
choices=InvenTree.status_codes.BuildStatus.items(),
default=10,
help_text="Build status code",
validators=[django.core.validators.MinValueValidator(0)],
validators=[
django.core.validators.MinValueValidator(0),
generic.states.validators.CustomStatusCodeValidator(
status_class=InvenTree.status_codes.BuildStatus
),
],
verbose_name="Build Status",
),
),

@ -43,7 +43,7 @@ from common.settings import (
get_global_setting,
prevent_build_output_complete_on_incompleted_tests,
)
from generic.states import StateTransitionMixin
from generic.states import StateTransitionMixin, StatusCodeMixin
from plugin.events import trigger_event
from stock.status_codes import StockHistoryCode, StockStatus
@ -59,6 +59,7 @@ class Build(
InvenTree.models.PluginValidationMixin,
InvenTree.models.ReferenceIndexingMixin,
StateTransitionMixin,
StatusCodeMixin,
MPTTModel,
):
"""A Build object organises the creation of new StockItem objects from other existing StockItem objects.
@ -84,6 +85,8 @@ class Build(
priority: Priority of the build
"""
STATUS_CLASS = BuildStatus
class Meta:
"""Metaclass options for the BuildOrder model."""
@ -319,6 +322,7 @@ class Build(
verbose_name=_('Build Status'),
default=BuildStatus.PENDING.value,
choices=BuildStatus.items(),
status_class=BuildStatus,
validators=[MinValueValidator(0)],
help_text=_('Build status code'),
)

@ -574,7 +574,9 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
)
status_custom_key = serializers.ChoiceField(
choices=StockStatus.items(), default=StockStatus.OK.value, label=_('Status')
choices=StockStatus.items(custom=True),
default=StockStatus.OK.value,
label=_('Status'),
)
accept_incomplete_allocation = serializers.BooleanField(

@ -0,0 +1,32 @@
# Generated by Django 4.2.17 on 2024-12-27 09:15
import common.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('common', '0033_delete_colortheme'),
]
operations = [
migrations.AlterUniqueTogether(
name='inventreecustomuserstatemodel',
unique_together=set(),
),
migrations.AlterField(
model_name='inventreecustomuserstatemodel',
name='key',
field=models.IntegerField(help_text='Numerical value that will be saved in the models database', verbose_name='Value'),
),
migrations.AlterField(
model_name='inventreecustomuserstatemodel',
name='name',
field=models.CharField(help_text='Name of the state', max_length=250, validators=[common.validators.validate_uppercase, common.validators.validate_variable_string], verbose_name='Name'),
),
migrations.AlterUniqueTogether(
name='inventreecustomuserstatemodel',
unique_together={('reference_status', 'name'), ('reference_status', 'key')},
),
]

@ -51,7 +51,7 @@ import InvenTree.validators
import users.models
from common.setting.type import InvenTreeSettingsKeyType, SettingsKeyType
from generic.states import ColorEnum
from generic.states.custom import get_custom_classes, state_color_mappings
from generic.states.custom import state_color_mappings
from InvenTree.sanitizer import sanitize_svg
logger = logging.getLogger('inventree')
@ -1927,20 +1927,59 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel
class InvenTreeCustomUserStateModel(models.Model):
"""Custom model to extends any registered state with extra custom, user defined states."""
"""Custom model to extends any registered state with extra custom, user defined states.
Fields:
reference_status: Status set that is extended with this custom state
logical_key: State logical key that is equal to this custom state in business logic
key: Numerical value that will be saved in the models database
name: Name of the state (must be uppercase and a valid variable identifier)
label: Label that will be displayed in the frontend (human readable)
color: Color that will be displayed in the frontend
"""
class Meta:
"""Metaclass options for this mixin."""
verbose_name = _('Custom State')
verbose_name_plural = _('Custom States')
unique_together = [('reference_status', 'key'), ('reference_status', 'name')]
reference_status = models.CharField(
max_length=250,
verbose_name=_('Reference Status Set'),
help_text=_('Status set that is extended with this custom state'),
)
logical_key = models.IntegerField(
verbose_name=_('Logical Key'),
help_text=_(
'State logical key that is equal to this custom state in business logic'
),
)
key = models.IntegerField(
verbose_name=_('Key'),
help_text=_('Value that will be saved in the models database'),
verbose_name=_('Value'),
help_text=_('Numerical value that will be saved in the models database'),
)
name = models.CharField(
max_length=250, verbose_name=_('Name'), help_text=_('Name of the state')
max_length=250,
verbose_name=_('Name'),
help_text=_('Name of the state'),
validators=[
common.validators.validate_uppercase,
common.validators.validate_variable_string,
],
)
label = models.CharField(
max_length=250,
verbose_name=_('Label'),
help_text=_('Label that will be displayed in the frontend'),
)
color = models.CharField(
max_length=10,
choices=state_color_mappings(),
@ -1948,12 +1987,7 @@ class InvenTreeCustomUserStateModel(models.Model):
verbose_name=_('Color'),
help_text=_('Color that will be displayed in the frontend'),
)
logical_key = models.IntegerField(
verbose_name=_('Logical Key'),
help_text=_(
'State logical key that is equal to this custom state in business logic'
),
)
model = models.ForeignKey(
ContentType,
on_delete=models.SET_NULL,
@ -1962,18 +1996,6 @@ class InvenTreeCustomUserStateModel(models.Model):
verbose_name=_('Model'),
help_text=_('Model this state is associated with'),
)
reference_status = models.CharField(
max_length=250,
verbose_name=_('Reference Status Set'),
help_text=_('Status set that is extended with this custom state'),
)
class Meta:
"""Metaclass options for this mixin."""
verbose_name = _('Custom State')
verbose_name_plural = _('Custom States')
unique_together = [['model', 'reference_status', 'key', 'logical_key']]
def __str__(self) -> str:
"""Return string representation of the custom state."""
@ -1999,38 +2021,50 @@ class InvenTreeCustomUserStateModel(models.Model):
if self.key == self.logical_key:
raise ValidationError({'key': _('Key must be different from logical key')})
if self.reference_status is None or self.reference_status == '':
# Check against the reference status class
status_class = self.get_status_class()
if not status_class:
raise ValidationError({
'reference_status': _('Reference status must be selected')
'reference_status': _('Valid reference status class must be provided')
})
# Ensure that the key is not in the range of the logical keys of the reference status
ref_set = list(
filter(
lambda x: x.__name__ == self.reference_status,
get_custom_classes(include_custom=False),
)
)
if len(ref_set) == 0:
raise ValidationError({
'reference_status': _('Reference status set not found')
})
ref_set = ref_set[0]
if self.key in ref_set.keys(): # noqa: SIM118
if self.key in status_class.values():
raise ValidationError({
'key': _(
'Key must be different from the logical keys of the reference status'
)
})
if self.logical_key not in ref_set.keys(): # noqa: SIM118
if self.logical_key not in status_class.values():
raise ValidationError({
'logical_key': _(
'Logical key must be in the logical keys of the reference status'
)
})
if self.name in status_class.names():
raise ValidationError({
'name': _(
'Name must be different from the names of the reference status'
)
})
return super().clean()
def get_status_class(self):
"""Return the appropriate status class for this custom state."""
from generic.states import StatusCode
from InvenTree.helpers import inheritors
if not self.reference_status:
return None
# Return the first class that matches the reference status
for cls in inheritors(StatusCode):
if cls.__name__ == self.reference_status:
return cls
class SelectionList(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
"""Class which represents a list of selectable items for parameters.

@ -355,6 +355,7 @@ class CustomStateSerializer(DataImportExportSerializerMixin, InvenTreeModelSeria
]
model_name = serializers.CharField(read_only=True, source='model.name')
reference_status = serializers.ChoiceField(
choices=generic.states.custom.state_reference_mappings()
)

@ -113,3 +113,17 @@ def validate_icon(name: Union[str, None]):
return
common.icons.validate_icon(name)
def validate_uppercase(value: str):
"""Ensure that the provided value is uppercase."""
value = str(value)
if value != value.upper():
raise ValidationError(_('Value must be uppercase'))
def validate_variable_string(value: str):
"""The passed value must be a valid variable identifier string."""
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', value):
raise ValidationError(_('Value must be a valid variable identifier'))

@ -25,6 +25,9 @@ database:
# HOST: Database host address (if required)
# PORT: Database host port (if required)
# Base URL for the InvenTree server (or use the environment variable INVENTREE_SITE_URL)
# site_url: 'http://localhost:8000'
# Set debug to False to run in production mode, or use the environment variable INVENTREE_DEBUG
debug: False
@ -45,8 +48,10 @@ log_level: WARNING
# Configure if logs should be output in JSON format
# Use environment variable INVENTREE_JSON_LOG
json_log: False
# Enable database-level logging, or use the environment variable INVENTREE_DB_LOGGING
db_logging: False
# Enable writing a log file, or use the environment variable INVENTREE_WRITE_LOG
write_log: False
@ -56,8 +61,6 @@ language: en-us
# 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 (or use the environment variable INVENTREE_SITE_URL)
site_url: 'http://localhost:8000'
# Add new user on first startup by either adding values here or from a file
#admin_user: admin
@ -114,14 +117,11 @@ allowed_hosts:
# - 'http://localhost'
# - 'http://*.localhost'
# Proxy forwarding settings
# If InvenTree is running behind a proxy, you may need to configure these settings
# Override with the environment variable INVENTREE_USE_X_FORWARDED_HOST
use_x_forwarded_host: false
# Override with the environment variable INVENTREE_USE_X_FORWARDED_PORT
use_x_forwarded_port: false
# Enable Proxy header passthrough
# Override with the environment variable INVENTREE_USE_X_FORWARDED_<HEADER>
# use_x_forwarded_host: true
# use_x_forwarded_port: true
# use_x_forwarded_proto: true
# Cookie settings (nominally the default settings should be fine)
cookie:
@ -160,7 +160,6 @@ cache:
host: 'inventree-cache'
port: 6379
# Login configuration
login_confirm_days: 3
login_attempts: 5
@ -206,10 +205,3 @@ ldap:
# hide_password_reset: true
# logo: img/custom_logo.png
# splash: img/custom_splash.jpg
# hide_pui_banner: true
# Set enabled frontends
# Use the environment variable INVENTREE_CLASSIC_FRONTEND
# classic_frontend: True
# Use the environment variable INVENTREE_PLATFORM_FRONTEND
# platform_frontend: True

@ -6,13 +6,14 @@ There is a rendered state for each state value. The rendered state is used for d
States can be extended with custom options for each InvenTree instance - those options are stored in the database and need to link back to state values.
"""
from .states import ColorEnum, StatusCode
from .states import ColorEnum, StatusCode, StatusCodeMixin
from .transition import StateTransitionMixin, TransitionMethod, storage
__all__ = [
'ColorEnum',
'StateTransitionMixin',
'StatusCode',
'StatusCodeMixin',
'TransitionMethod',
'storage',
]

@ -11,14 +11,13 @@ from rest_framework.response import Response
import common.models
import common.serializers
from generic.states.custom import get_status_api_response
from importer.mixins import DataExportViewMixin
from InvenTree.filters import SEARCH_ORDER_FILTER
from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI
from InvenTree.permissions import IsStaffOrReadOnly
from InvenTree.serializers import EmptySerializer
from machine.machine_type import MachineStatus
from .serializers import GenericStateClassSerializer
from .states import StatusCode
@ -38,6 +37,7 @@ class StatusView(GenericAPIView):
"""
permission_classes = [permissions.IsAuthenticated]
serializer_class = GenericStateClassSerializer
# Override status_class for implementing subclass
MODEL_REF = 'statusmodel'
@ -56,7 +56,7 @@ class StatusView(GenericAPIView):
@extend_schema(
description='Retrieve information about a specific status code',
responses={
200: OpenApiResponse(description='Status code information'),
200: GenericStateClassSerializer,
400: OpenApiResponse(description='Invalid request'),
},
)
@ -70,9 +70,27 @@ class StatusView(GenericAPIView):
if not issubclass(status_class, StatusCode):
raise NotImplementedError('`status_class` not a valid StatusCode class')
data = {'class': status_class.__name__, 'values': status_class.dict()}
data = {'status_class': status_class.__name__, 'values': status_class.dict()}
return Response(data)
# Extend with custom values
try:
custom_values = status_class.custom_values()
for item in custom_values:
if item.name not in data['values']:
data['values'][item.name] = {
'color': item.color,
'logical_key': item.logical_key,
'key': item.key,
'label': item.label,
'name': item.name,
'custom': True,
}
except Exception:
pass
serializer = GenericStateClassSerializer(data, many=False)
return Response(serializer.data)
class AllStatusViews(StatusView):
@ -83,9 +101,32 @@ class AllStatusViews(StatusView):
def get(self, request, *args, **kwargs):
"""Perform a GET request to learn information about status codes."""
data = get_status_api_response()
# Extend with MachineStatus classes
data.update(get_status_api_response(MachineStatus, prefix=['MachineStatus']))
from InvenTree.helpers import inheritors
data = {}
# Find all inherited status classes
status_classes = inheritors(StatusCode)
for cls in status_classes:
cls_data = {'status_class': cls.__name__, 'values': cls.dict()}
# Extend with custom values
for item in cls.custom_values():
label = str(item.name)
if label not in cls_data['values']:
print('custom value:', item)
cls_data['values'][label] = {
'color': item.color,
'logical_key': item.logical_key,
'key': item.key,
'label': item.label,
'name': item.name,
'custom': True,
}
data[cls.__name__] = GenericStateClassSerializer(cls_data, many=False).data
return Response(data)
@ -99,6 +140,7 @@ class CustomStateList(DataExportViewMixin, ListCreateAPI):
filter_backends = SEARCH_ORDER_FILTER
ordering_fields = ['key']
search_fields = ['key', 'name', 'label', 'reference_status']
filterset_fields = ['model', 'reference_status']
class CustomStateDetail(RetrieveUpdateDestroyAPI):

@ -7,23 +7,7 @@ from .states import ColorEnum, StatusCode
def get_custom_status_labels(include_custom: bool = True):
"""Return a dict of custom status labels."""
return {cls.tag(): cls for cls in get_custom_classes(include_custom)}
def get_status_api_response(base_class=StatusCode, prefix=None):
"""Return a dict of status classes (custom and class defined).
Args:
base_class: The base class to search for subclasses.
prefix: A list of strings to prefix the class names with.
"""
return {
'__'.join([*(prefix or []), k.__name__]): {
'class': k.__name__,
'values': k.dict(),
}
for k in get_custom_classes(base_class=base_class, subclass=False)
}
return {cls.tag(): cls for cls in inheritors(StatusCode)}
def state_color_mappings():
@ -33,7 +17,7 @@ def state_color_mappings():
def state_reference_mappings():
"""Return a list of custom user state references."""
classes = get_custom_classes(include_custom=False)
classes = inheritors(StatusCode)
return [(a.__name__, a.__name__) for a in sorted(classes, key=lambda x: x.__name__)]
@ -42,48 +26,3 @@ def get_logical_value(value, model: str):
from common.models import InvenTreeCustomUserStateModel
return InvenTreeCustomUserStateModel.objects.get(key=value, model__model=model)
def get_custom_classes(
include_custom: bool = True, base_class=StatusCode, subclass=False
):
"""Return a dict of status classes (custom and class defined)."""
discovered_classes = inheritors(base_class, subclass)
if not include_custom:
return discovered_classes
# Gather DB settings
from common.models import InvenTreeCustomUserStateModel
custom_db_states = {}
custom_db_mdls = {}
for item in list(InvenTreeCustomUserStateModel.objects.all()):
if not custom_db_states.get(item.reference_status):
custom_db_states[item.reference_status] = []
custom_db_states[item.reference_status].append(item)
custom_db_mdls[item.model.app_label] = item.reference_status
custom_db_mdls_keys = custom_db_mdls.keys()
states = {}
for cls in discovered_classes:
tag = cls.tag()
states[tag] = cls
if custom_db_mdls and tag in custom_db_mdls_keys:
data = [(str(m.name), (m.value, m.label, m.color)) for m in states[tag]]
data_keys = [i[0] for i in data]
# Extent with non present tags
for entry in custom_db_states[custom_db_mdls[tag]]:
ref_name = str(entry.name.upper().replace(' ', ''))
if ref_name not in data_keys:
data += [
(
str(entry.name.upper().replace(' ', '')),
(entry.key, entry.label, entry.color),
)
]
# Re-assemble the enum
states[tag] = base_class(f'{tag.capitalize()}Status', data)
return states.values()

@ -90,6 +90,20 @@ class InvenTreeCustomStatusModelField(models.PositiveIntegerField):
Models using this model field must also include the InvenTreeCustomStatusSerializerMixin in all serializers that create or update the value.
"""
def __init__(self, *args, **kwargs):
"""Initialize the field."""
from generic.states.validators import CustomStatusCodeValidator
self.status_class = kwargs.pop('status_class', None)
validators = kwargs.pop('validators', None) or []
if self.status_class:
validators.append(CustomStatusCodeValidator(status_class=self.status_class))
kwargs['validators'] = validators
super().__init__(*args, **kwargs)
def deconstruct(self):
"""Deconstruct the field for migrations."""
name, path, args, kwargs = super().deconstruct()
@ -109,14 +123,23 @@ class InvenTreeCustomStatusModelField(models.PositiveIntegerField):
"""Ensure that the value is not an empty string."""
if value == '':
value = None
return super().clean(value, model_instance)
def add_field(self, cls, name):
"""Adds custom_key_field to the model class to save additional status information."""
from generic.states.validators import CustomStatusCodeValidator
validators = []
if self.status_class:
validators.append(CustomStatusCodeValidator(status_class=self.status_class))
custom_key_field = ExtraInvenTreeCustomStatusModelField(
default=None,
verbose_name=_('Custom status key'),
help_text=_('Additional status information for this item'),
validators=validators,
blank=True,
null=True,
)
@ -130,6 +153,10 @@ class ExtraInvenTreeCustomStatusModelField(models.PositiveIntegerField):
This is not intended to be used directly, if you want to support custom states in your model use InvenTreeCustomStatusModelField.
"""
def __init__(self, *args, **kwargs):
"""Initialize the field."""
super().__init__(*args, **kwargs)
class InvenTreeCustomStatusSerializerMixin:
"""Mixin to ensure custom status fields are set.

@ -0,0 +1,41 @@
"""Serializer classes for handling generic state information."""
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
class GenericStateValueSerializer(serializers.Serializer):
"""API serializer for generic state information."""
class Meta:
"""Meta class for GenericStateValueSerializer."""
fields = ['key', 'logical_key', 'name', 'label', 'color', 'custom']
key = serializers.IntegerField(label=_('Key'), required=True)
logical_key = serializers.CharField(label=_('Logical Key'), required=False)
name = serializers.CharField(label=_('Name'), required=True)
label = serializers.CharField(label=_('Label'), required=True)
color = serializers.CharField(label=_('Color'), required=False)
custom = serializers.BooleanField(label=_('Custom'), required=False)
class GenericStateClassSerializer(serializers.Serializer):
"""API serializer for generic state class information."""
class Meta:
"""Meta class for GenericStateClassSerializer."""
fields = ['status_class', 'values']
status_class = serializers.CharField(label=_('Class'), read_only=True)
values = serializers.DictField(
child=GenericStateValueSerializer(), label=_('Values'), required=True
)

@ -1,9 +1,12 @@
"""Generic implementation of status for InvenTree models."""
import enum
import logging
import re
from enum import Enum
logger = logging.getLogger('inventree')
class BaseEnum(enum.IntEnum): # noqa: PLW1641
"""An `Enum` capabile of having its members have docstrings.
@ -102,10 +105,30 @@ class StatusCode(BaseEnum):
return False
return isinstance(value.value, int)
@classmethod
def custom_queryset(cls):
"""Return a queryset of all custom values for this status class."""
from common.models import InvenTreeCustomUserStateModel
try:
return InvenTreeCustomUserStateModel.objects.filter(
reference_status=cls.__name__
)
except Exception:
return None
@classmethod
def custom_values(cls):
"""Return all user-defined custom values for this status class."""
if query := cls.custom_queryset():
return list(query)
return []
@classmethod
def values(cls, key=None):
"""Return a dict representation containing all required information."""
elements = [itm for itm in cls if cls._is_element(itm.name)]
if key is None:
return elements
@ -138,19 +161,28 @@ class StatusCode(BaseEnum):
return re.sub(r'(?<!^)(?=[A-Z])', '_', ref_name).lower()
@classmethod
def items(cls):
def items(cls, custom=False):
"""All status code items."""
return [(x.value, x.label) for x in cls.values()]
data = [(x.value, x.label) for x in cls.values()]
if custom:
try:
for item in cls.custom_values():
data.append((item.key, item.label))
except Exception:
pass
return data
@classmethod
def keys(cls):
def keys(cls, custom=True):
"""All status code keys."""
return [x.value for x in cls.values()]
return [el[0] for el in cls.items(custom=custom)]
@classmethod
def labels(cls):
def labels(cls, custom=True):
"""All status code labels."""
return [x.label for x in cls.values()]
return [el[1] for el in cls.items(custom=custom)]
@classmethod
def names(cls):
@ -174,23 +206,42 @@ class StatusCode(BaseEnum):
return filtered.label
@classmethod
def dict(cls, key=None):
def dict(cls, key=None, custom=True):
"""Return a dict representation containing all required information."""
return {
data = {
x.name: {'color': x.color, 'key': x.value, 'label': x.label, 'name': x.name}
for x in cls.values(key)
}
@classmethod
def list(cls):
"""Return the StatusCode options as a list of mapped key / value items."""
return list(cls.dict().values())
if custom:
try:
for item in cls.custom_values():
if item.name not in data:
data[item.name] = {
'color': item.color,
'key': item.key,
'label': item.label,
'name': item.name,
'custom': True,
}
except Exception:
pass
return data
@classmethod
def template_context(cls):
def list(cls, custom=True):
"""Return the StatusCode options as a list of mapped key / value items."""
return list(cls.dict(custom=custom).values())
@classmethod
def template_context(cls, custom=True):
"""Return a dict representation containing all required information for templates."""
ret = {x.name: x.value for x in cls.values()}
ret['list'] = cls.list()
data = cls.dict(custom=custom)
ret = {x['name']: x['key'] for x in data.values()}
ret['list'] = list(data.values())
return ret
@ -205,3 +256,78 @@ class ColorEnum(Enum):
warning = 'warning'
info = 'info'
dark = 'dark'
class StatusCodeMixin:
"""Mixin class which handles custom 'status' fields.
- Implements a 'set_stutus' method which can be used to set the status of an object
- Implements a 'get_status' method which can be used to retrieve the status of an object
This mixin assumes that the implementing class has a 'status' field,
which must be an instance of the InvenTreeCustomStatusModelField class.
"""
STATUS_CLASS = None
STATUS_FIELD = 'status'
@property
def status_class(self):
"""Return the status class associated with this model."""
return self.STATUS_CLASS
def save(self, *args, **kwargs):
"""Custom save method for StatusCodeMixin.
- Ensure custom status code values are correctly updated
"""
if self.status_class:
# Check that the current 'logical key' actually matches the current status code
custom_values = self.status_class.custom_queryset().filter(
logical_key=self.get_status(), key=self.get_custom_status()
)
if not custom_values.exists():
# No match - null out the custom value
setattr(self, f'{self.STATUS_FIELD}_custom_key', None)
super().save(*args, **kwargs)
def get_status(self) -> int:
"""Return the status code for this object."""
return getattr(self, self.STATUS_FIELD)
def get_custom_status(self) -> int:
"""Return the custom status code for this object."""
return getattr(self, f'{self.STATUS_FIELD}_custom_key', None)
def set_status(self, status: int) -> bool:
"""Set the status code for this object."""
if not self.status_class:
raise NotImplementedError('Status class not defined')
base_values = self.status_class.values()
custom_value_set = self.status_class.custom_values()
custom_field = f'{self.STATUS_FIELD}_custom_key'
result = False
if status in base_values:
# Set the status to a 'base' value
setattr(self, self.STATUS_FIELD, status)
setattr(self, custom_field, None)
result = True
else:
for item in custom_value_set:
if item.key == status:
# Set the status to a 'custom' value
setattr(self, self.STATUS_FIELD, item.logical_key)
setattr(self, custom_field, item.key)
result = True
break
if not result:
logger.warning(f'Failed to set status {status} for class {self.__class__}')
return result

@ -174,10 +174,10 @@ class GeneralStateTest(InvenTreeTestCase):
# Correct call
resp = view(rqst, **{StatusView.MODEL_REF: GeneralStatus})
self.assertEqual(
self.assertDictEqual(
resp.data,
{
'class': 'GeneralStatus',
'status_class': 'GeneralStatus',
'values': {
'COMPLETE': {
'key': 30,
@ -228,11 +228,13 @@ class ApiTests(InvenTreeAPITestCase):
def test_all_states(self):
"""Test the API endpoint for listing all status models."""
response = self.get(reverse('api-status-all'))
# 10 built-in state classes, plus the added GeneralState class
self.assertEqual(len(response.data), 12)
# Test the BuildStatus model
build_status = response.data['BuildStatus']
self.assertEqual(build_status['class'], 'BuildStatus')
self.assertEqual(build_status['status_class'], 'BuildStatus')
self.assertEqual(len(build_status['values']), 5)
pending = build_status['values']['PENDING']
self.assertEqual(pending['key'], 10)
@ -241,7 +243,7 @@ class ApiTests(InvenTreeAPITestCase):
# Test the StockStatus model (static)
stock_status = response.data['StockStatus']
self.assertEqual(stock_status['class'], 'StockStatus')
self.assertEqual(stock_status['status_class'], 'StockStatus')
self.assertEqual(len(stock_status['values']), 8)
in_stock = stock_status['values']['OK']
self.assertEqual(in_stock['key'], 10)
@ -249,8 +251,8 @@ class ApiTests(InvenTreeAPITestCase):
self.assertEqual(in_stock['label'], 'OK')
# MachineStatus model
machine_status = response.data['MachineStatus__LabelPrinterStatus']
self.assertEqual(machine_status['class'], 'LabelPrinterStatus')
machine_status = response.data['LabelPrinterStatus']
self.assertEqual(machine_status['status_class'], 'LabelPrinterStatus')
self.assertEqual(len(machine_status['values']), 6)
connected = machine_status['values']['CONNECTED']
self.assertEqual(connected['key'], 100)
@ -267,10 +269,11 @@ class ApiTests(InvenTreeAPITestCase):
reference_status='StockStatus',
)
response = self.get(reverse('api-status-all'))
self.assertEqual(len(response.data), 12)
stock_status_cstm = response.data['StockStatus']
self.assertEqual(stock_status_cstm['class'], 'StockStatus')
self.assertEqual(stock_status_cstm['status_class'], 'StockStatus')
self.assertEqual(len(stock_status_cstm['values']), 9)
ok_advanced = stock_status_cstm['values']['OK']
self.assertEqual(ok_advanced['key'], 10)

@ -0,0 +1,21 @@
"""Validators for generic state management."""
from django.core.exceptions import ValidationError
from django.core.validators import BaseValidator
from django.utils.translation import gettext_lazy as _
class CustomStatusCodeValidator(BaseValidator):
"""Custom validator class for checking that a provided status code is valid."""
def __init__(self, *args, **kwargs):
"""Initialize the validator."""
self.status_class = kwargs.pop('status_class', None)
super().__init__(limit_value=None, **kwargs)
def __call__(self, value):
"""Check that the provided status code is valid."""
if status_class := self.status_class:
values = status_class.keys(custom=True)
if value not in values:
raise ValidationError(_('Invalid status code'))

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

@ -82,8 +82,14 @@ class OrderFilter(rest_filters.FilterSet):
status = rest_filters.NumberFilter(label=_('Order Status'), method='filter_status')
def filter_status(self, queryset, name, value):
"""Filter by integer status code."""
return queryset.filter(status=value)
"""Filter by integer status code.
Note: Also account for the possibility of a custom status code.
"""
q1 = Q(status=value, status_custom_key__isnull=True)
q2 = Q(status_custom_key=value)
return queryset.filter(q1 | q2).distinct()
# Exact match for reference
reference = rest_filters.CharFilter(

@ -3,6 +3,7 @@
from django.db import migrations
import generic.states.fields
import generic.states.validators
import InvenTree.status_codes
@ -22,6 +23,11 @@ class Migration(migrations.Migration):
help_text="Additional status information for this item",
null=True,
verbose_name="Custom status key",
validators=[
generic.states.validators.CustomStatusCodeValidator(
status_class=InvenTree.status_codes.PurchaseOrderStatus
),
]
),
),
migrations.AddField(
@ -33,6 +39,11 @@ class Migration(migrations.Migration):
help_text="Additional status information for this item",
null=True,
verbose_name="Custom status key",
validators=[
generic.states.validators.CustomStatusCodeValidator(
status_class=InvenTree.status_codes.ReturnOrderStatus
),
]
),
),
migrations.AddField(
@ -44,6 +55,11 @@ class Migration(migrations.Migration):
help_text="Additional status information for this item",
null=True,
verbose_name="Custom status key",
validators=[
generic.states.validators.CustomStatusCodeValidator(
status_class=InvenTree.status_codes.ReturnOrderLineStatus
),
]
),
),
migrations.AddField(
@ -55,6 +71,11 @@ class Migration(migrations.Migration):
help_text="Additional status information for this item",
null=True,
verbose_name="Custom status key",
validators=[
generic.states.validators.CustomStatusCodeValidator(
status_class=InvenTree.status_codes.SalesOrderStatus
),
]
),
),
migrations.AlterField(
@ -65,6 +86,11 @@ class Migration(migrations.Migration):
default=10,
help_text="Purchase order status",
verbose_name="Status",
validators=[
generic.states.validators.CustomStatusCodeValidator(
status_class=InvenTree.status_codes.PurchaseOrderStatus
),
]
),
),
migrations.AlterField(
@ -75,6 +101,11 @@ class Migration(migrations.Migration):
default=10,
help_text="Return order status",
verbose_name="Status",
validators=[
generic.states.validators.CustomStatusCodeValidator(
status_class=InvenTree.status_codes.ReturnOrderStatus
),
]
),
),
migrations.AlterField(
@ -85,6 +116,11 @@ class Migration(migrations.Migration):
default=10,
help_text="Outcome for this line item",
verbose_name="Outcome",
validators=[
generic.states.validators.CustomStatusCodeValidator(
status_class=InvenTree.status_codes.ReturnOrderLineStatus
),
]
),
),
migrations.AlterField(
@ -95,6 +131,11 @@ class Migration(migrations.Migration):
default=10,
help_text="Sales order status",
verbose_name="Status",
validators=[
generic.states.validators.CustomStatusCodeValidator(
status_class=InvenTree.status_codes.SalesOrderStatus
),
]
),
),
]

@ -34,7 +34,7 @@ from common.currency import currency_code_default
from common.notifications import InvenTreeNotificationBodies
from common.settings import get_global_setting
from company.models import Address, Company, Contact, SupplierPart
from generic.states import StateTransitionMixin
from generic.states import StateTransitionMixin, StatusCodeMixin
from generic.states.fields import InvenTreeCustomStatusModelField
from InvenTree.exceptions import log_error
from InvenTree.fields import (
@ -179,6 +179,7 @@ class TotalPriceMixin(models.Model):
class Order(
StatusCodeMixin,
StateTransitionMixin,
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin,
@ -379,6 +380,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
REFERENCE_PATTERN_SETTING = 'PURCHASEORDER_REFERENCE_PATTERN'
REQUIRE_RESPONSIBLE_SETTING = 'PURCHASEORDER_REQUIRE_RESPONSIBLE'
STATUS_CLASS = PurchaseOrderStatus
class Meta:
"""Model meta options."""
@ -483,6 +485,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
status = InvenTreeCustomStatusModelField(
default=PurchaseOrderStatus.PENDING.value,
choices=PurchaseOrderStatus.items(),
status_class=PurchaseOrderStatus,
verbose_name=_('Status'),
help_text=_('Purchase order status'),
)
@ -912,6 +915,7 @@ class SalesOrder(TotalPriceMixin, Order):
REFERENCE_PATTERN_SETTING = 'SALESORDER_REFERENCE_PATTERN'
REQUIRE_RESPONSIBLE_SETTING = 'SALESORDER_REQUIRE_RESPONSIBLE'
STATUS_CLASS = SalesOrderStatus
class Meta:
"""Model meta options."""
@ -1029,6 +1033,7 @@ class SalesOrder(TotalPriceMixin, Order):
status = InvenTreeCustomStatusModelField(
default=SalesOrderStatus.PENDING.value,
choices=SalesOrderStatus.items(),
status_class=SalesOrderStatus,
verbose_name=_('Status'),
help_text=_('Sales order status'),
)
@ -2155,6 +2160,7 @@ class ReturnOrder(TotalPriceMixin, Order):
REFERENCE_PATTERN_SETTING = 'RETURNORDER_REFERENCE_PATTERN'
REQUIRE_RESPONSIBLE_SETTING = 'RETURNORDER_REQUIRE_RESPONSIBLE'
STATUS_CLASS = ReturnOrderStatus
class Meta:
"""Model meta options."""
@ -2231,6 +2237,7 @@ class ReturnOrder(TotalPriceMixin, Order):
status = InvenTreeCustomStatusModelField(
default=ReturnOrderStatus.PENDING.value,
choices=ReturnOrderStatus.items(),
status_class=ReturnOrderStatus,
verbose_name=_('Status'),
help_text=_('Return order status'),
)
@ -2446,9 +2453,12 @@ class ReturnOrder(TotalPriceMixin, Order):
)
class ReturnOrderLineItem(OrderLineItem):
class ReturnOrderLineItem(StatusCodeMixin, OrderLineItem):
"""Model for a single LineItem in a ReturnOrder."""
STATUS_CLASS = ReturnOrderLineStatus
STATUS_FIELD = 'outcome'
class Meta:
"""Metaclass options for this model."""
@ -2522,6 +2532,7 @@ class ReturnOrderLineItem(OrderLineItem):
outcome = InvenTreeCustomStatusModelField(
default=ReturnOrderLineStatus.PENDING.value,
choices=ReturnOrderLineStatus.items(),
status_class=ReturnOrderLineStatus,
verbose_name=_('Outcome'),
help_text=_('Outcome for this line item'),
)

@ -769,7 +769,9 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
)
status = serializers.ChoiceField(
choices=StockStatus.items(), default=StockStatus.OK.value, label=_('Status')
choices=StockStatus.items(custom=True),
default=StockStatus.OK.value,
label=_('Status'),
)
packaging = serializers.CharField(
@ -1935,7 +1937,7 @@ class ReturnOrderLineItemReceiveSerializer(serializers.Serializer):
)
status = serializers.ChoiceField(
choices=stock.status_codes.StockStatus.items(),
choices=stock.status_codes.StockStatus.items(custom=True),
default=None,
label=_('Status'),
help_text=_('Stock item status code'),

@ -1,80 +0,0 @@
"""This script calculates translation coverage for various languages."""
import json
import os
import sys
def calculate_coverage(filename):
"""Calculate translation coverage for a .po file."""
with open(filename, encoding='utf-8') as f:
lines = f.readlines()
lines_count = 0
lines_covered = 0
lines_uncovered = 0
for line in lines:
if line.startswith('msgid '):
lines_count += 1
elif line.startswith('msgstr'):
if line.startswith(('msgstr ""', "msgstr ''")):
lines_uncovered += 1
else:
lines_covered += 1
# Return stats for the file
return (lines_count, lines_covered, lines_uncovered)
if __name__ == '__main__':
MY_DIR = os.path.dirname(os.path.realpath(__file__))
LC_DIR = os.path.abspath(os.path.join(MY_DIR, '..', 'locale'))
STAT_FILE = os.path.abspath(
os.path.join(MY_DIR, '..', 'InvenTree/locale_stats.json')
)
locales = {}
locales_perc = {}
verbose = '-v' in sys.argv
for locale in os.listdir(LC_DIR):
path = os.path.join(LC_DIR, locale)
if os.path.exists(path) and os.path.isdir(path):
locale_file = os.path.join(path, 'LC_MESSAGES', 'django.po')
if os.path.exists(locale_file) and os.path.isfile(locale_file):
locales[locale] = locale_file
if verbose:
print('-' * 16)
percentages = []
for locale in locales:
locale_file = locales[locale]
stats = calculate_coverage(locale_file)
(total, covered, uncovered) = stats
percentage = int(covered / total * 100) if total > 0 else 0
if verbose:
print(f'| {locale.ljust(4, " ")} : {str(percentage).rjust(4, " ")}% |')
locales_perc[locale] = percentage
percentages.append(percentage)
if verbose:
print('-' * 16)
# write locale stats
with open(STAT_FILE, 'w', encoding='utf-8') as target:
json.dump(locales_perc, target)
avg = int(sum(percentages) / len(percentages)) if len(percentages) > 0 else 0
print(f'InvenTree translation coverage: {avg}%')

@ -571,8 +571,14 @@ class StockFilter(rest_filters.FilterSet):
status = rest_filters.NumberFilter(label=_('Status Code'), method='filter_status')
def filter_status(self, queryset, name, value):
"""Filter by integer status code."""
return queryset.filter(status=value)
"""Filter by integer status code.
Note: Also account for the possibility of a custom status code.
"""
q1 = Q(status=value, status_custom_key__isnull=True)
q2 = Q(status_custom_key=value)
return queryset.filter(q1 | q2).distinct()
allocated = rest_filters.BooleanFilter(
label='Is Allocated', method='filter_allocated'

@ -5,6 +5,7 @@ from django.db import migrations
import generic.states
import generic.states.fields
import generic.states.validators
import InvenTree.status_codes
@ -24,6 +25,11 @@ class Migration(migrations.Migration):
help_text="Additional status information for this item",
null=True,
verbose_name="Custom status key",
validators=[
generic.states.validators.CustomStatusCodeValidator(
status_class=InvenTree.status_codes.StockStatus
),
],
),
),
migrations.AlterField(
@ -32,7 +38,12 @@ class Migration(migrations.Migration):
field=generic.states.fields.InvenTreeCustomStatusModelField(
choices=InvenTree.status_codes.StockStatus.items(),
default=10,
validators=[django.core.validators.MinValueValidator(0)],
validators=[
django.core.validators.MinValueValidator(0),
generic.states.validators.CustomStatusCodeValidator(
status_class=InvenTree.status_codes.StockStatus
),
],
),
),
]

@ -37,6 +37,7 @@ import stock.tasks
from common.icons import validate_icon
from common.settings import get_global_setting
from company import models as CompanyModels
from generic.states import StatusCodeMixin
from generic.states.fields import InvenTreeCustomStatusModelField
from InvenTree.fields import InvenTreeModelMoneyField, InvenTreeURLField
from InvenTree.status_codes import (
@ -340,6 +341,7 @@ class StockItem(
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin,
StatusCodeMixin,
report.mixins.InvenTreeReportMixin,
InvenTree.models.MetadataMixin,
InvenTree.models.PluginValidationMixin,
@ -373,6 +375,8 @@ class StockItem(
packaging: Description of how the StockItem is packaged (e.g. "reel", "loose", "tape" etc)
"""
STATUS_CLASS = StockStatus
class Meta:
"""Model meta options."""
@ -1020,6 +1024,7 @@ class StockItem(
status = InvenTreeCustomStatusModelField(
default=StockStatus.OK.value,
status_class=StockStatus,
choices=StockStatus.items(),
validators=[MinValueValidator(0)],
)
@ -2137,6 +2142,12 @@ class StockItem(
else:
tracking_info['location'] = location.pk
status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None)
if status and status != self.status:
self.set_status(status)
tracking_info['status'] = status
# Optional fields which can be supplied in a 'move' call
for field in StockItem.optional_transfer_fields():
if field in kwargs:
@ -2214,8 +2225,16 @@ class StockItem(
if count < 0:
return False
tracking_info = {}
status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None)
if status and status != self.status:
self.set_status(status)
tracking_info['status'] = status
if self.updateQuantity(count):
tracking_info = {'quantity': float(count)}
tracking_info['quantity'] = float(count)
self.stocktake_date = InvenTree.helpers.current_date()
self.stocktake_user = user
@ -2269,8 +2288,17 @@ class StockItem(
if quantity <= 0:
return False
tracking_info = {}
status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None)
if status and status != self.status:
self.set_status(status)
tracking_info['status'] = status
if self.updateQuantity(self.quantity + quantity):
tracking_info = {'added': float(quantity), 'quantity': float(self.quantity)}
tracking_info['added'] = float(quantity)
tracking_info['quantity'] = float(self.quantity)
# Optional fields which can be supplied in a 'stocktake' call
for field in StockItem.optional_transfer_fields():
@ -2314,8 +2342,17 @@ class StockItem(
if quantity <= 0:
return False
deltas = {}
status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None)
if status and status != self.status:
self.set_status(status)
deltas['status'] = status
if self.updateQuantity(self.quantity - quantity):
deltas = {'removed': float(quantity), 'quantity': float(self.quantity)}
deltas['removed'] = float(quantity)
deltas['quantity'] = float(self.quantity)
if location := kwargs.get('location'):
deltas['location'] = location.pk

@ -980,7 +980,7 @@ class ReturnStockItemSerializer(serializers.Serializer):
)
status = serializers.ChoiceField(
choices=stock.status_codes.StockStatus.items(),
choices=stock.status_codes.StockStatus.items(custom=True),
default=None,
label=_('Status'),
help_text=_('Stock item status code'),
@ -996,7 +996,7 @@ class ReturnStockItemSerializer(serializers.Serializer):
)
def save(self):
"""Save the serialzier to return the item into stock."""
"""Save the serializer to return the item into stock."""
item = self.context['item']
request = self.context['request']
@ -1037,7 +1037,7 @@ class StockChangeStatusSerializer(serializers.Serializer):
return items
status = serializers.ChoiceField(
choices=stock.status_codes.StockStatus.items(),
choices=stock.status_codes.StockStatus.items(custom=True),
default=stock.status_codes.StockStatus.OK.value,
label=_('Status'),
)
@ -1533,11 +1533,11 @@ def stock_item_adjust_status_options():
In particular, include a Null option for the status field.
"""
return [(None, _('No Change')), *stock.status_codes.StockStatus.items()]
return [(None, _('No Change')), *stock.status_codes.StockStatus.items(custom=True)]
class StockAdjustmentItemSerializer(serializers.Serializer):
"""Serializer for a single StockItem within a stock adjument request.
"""Serializer for a single StockItem within a stock adjustment request.
Required Fields:
- item: StockItem object

@ -1,7 +1,7 @@
import { t } from '@lingui/macro';
import { Box, Divider, Modal } from '@mantine/core';
import { useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { type NavigateFunction, useNavigate } from 'react-router-dom';
import { api } from '../../App';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import type { ModelType } from '../../enums/ModelType';
@ -17,17 +17,34 @@ export default function BarcodeScanDialog({
title,
opened,
onClose
}: {
}: Readonly<{
title?: string;
opened: boolean;
onClose: () => void;
}) {
}>) {
const navigate = useNavigate();
const user = useUserState();
return (
<Modal
size='lg'
opened={opened}
onClose={onClose}
title={<StylishText size='xl'>{title ?? t`Scan Barcode`}</StylishText>}
>
<Divider />
<Box>
<ScanInputHandler navigate={navigate} onClose={onClose} />
</Box>
</Modal>
);
}
export function ScanInputHandler({
onClose,
navigate
}: Readonly<{ onClose: () => void; navigate: NavigateFunction }>) {
const [error, setError] = useState<string>('');
const [processing, setProcessing] = useState<boolean>(false);
const user = useUserState();
const onScan = useCallback((barcode: string) => {
if (!barcode || barcode.length === 0) {
@ -80,19 +97,5 @@ export default function BarcodeScanDialog({
});
}, []);
return (
<>
<Modal
size='lg'
opened={opened}
onClose={onClose}
title={<StylishText size='xl'>{title ?? t`Scan Barcode`}</StylishText>}
>
<Divider />
<Box>
<BarcodeInput onScan={onScan} error={error} processing={processing} />
</Box>
</Modal>
</>
);
return <BarcodeInput onScan={onScan} error={error} processing={processing} />;
}

@ -0,0 +1,21 @@
import {} from '@mantine/core';
import type { ContextModalProps } from '@mantine/modals';
import type { NavigateFunction } from 'react-router-dom';
import { ScanInputHandler } from '../barcodes/BarcodeScanDialog';
export function QrModal({
context,
id,
innerProps
}: Readonly<
ContextModalProps<{ modalBody: string; navigate: NavigateFunction }>
>) {
function close() {
context.closeModal(id);
}
function navigate() {
context.closeModal(id);
}
return <ScanInputHandler navigate={innerProps.navigate} onClose={close} />;
}

@ -1,17 +1,24 @@
import { LoadingOverlay } from '@mantine/core';
import type { ModelType } from '../../enums/ModelType';
import type { UserRoles } from '../../enums/Roles';
import { useUserState } from '../../states/UserState';
import ClientError from '../errors/ClientError';
import PermissionDenied from '../errors/PermissionDenied';
import ServerError from '../errors/ServerError';
export default function InstanceDetail({
status,
loading,
children
children,
requiredRole,
requiredPermission
}: Readonly<{
status: number;
loading: boolean;
children: React.ReactNode;
requiredRole?: UserRoles;
requiredPermission?: ModelType;
}>) {
const user = useUserState();
@ -27,5 +34,13 @@ export default function InstanceDetail({
return <ClientError status={status} />;
}
if (requiredRole && !user.hasViewRole(requiredRole)) {
return <PermissionDenied />;
}
if (requiredPermission && !user.hasViewPermission(requiredPermission)) {
return <PermissionDenied />;
}
return <>{children}</>;
}

@ -1,19 +1,22 @@
import { Badge, Center, type MantineSize } from '@mantine/core';
import { colorMap } from '../../defaults/backendMappings';
import { statusColorMap } from '../../defaults/backendMappings';
import type { ModelType } from '../../enums/ModelType';
import { resolveItem } from '../../functions/conversion';
import { useGlobalStatusState } from '../../states/StatusState';
interface StatusCodeInterface {
key: string;
export interface StatusCodeInterface {
key: number;
label: string;
name: string;
color: string;
}
export interface StatusCodeListInterface {
status_class: string;
values: {
[key: string]: StatusCodeInterface;
};
}
interface RenderStatusLabelOptionsInterface {
@ -33,10 +36,10 @@ function renderStatusLabel(
let color = null;
// Find the entry which matches the provided key
for (const name in codes) {
const entry = codes[name];
for (const name in codes.values) {
const entry: StatusCodeInterface = codes.values[name];
if (entry.key == key) {
if (entry?.key == key) {
text = entry.label;
color = entry.color;
break;
@ -51,7 +54,7 @@ function renderStatusLabel(
// Fallbacks
if (color == null) color = 'default';
color = colorMap[color] || colorMap['default'];
color = statusColorMap[color] || statusColorMap['default'];
const size = options.size || 'xs';
if (!text) {
@ -65,7 +68,9 @@ function renderStatusLabel(
);
}
export function getStatusCodes(type: ModelType | string) {
export function getStatusCodes(
type: ModelType | string
): StatusCodeListInterface | null {
const statusCodeList = useGlobalStatusState.getState().status;
if (statusCodeList === undefined) {
@ -97,7 +102,7 @@ export function getStatusCodeName(
}
for (const name in statusCodes) {
const entry = statusCodes[name];
const entry: StatusCodeInterface = statusCodes.values[name];
if (entry.key == key) {
return entry.name;

@ -6,6 +6,7 @@ import { ContextMenuProvider } from 'mantine-contextmenu';
import { AboutInvenTreeModal } from '../components/modals/AboutInvenTreeModal';
import { LicenseModal } from '../components/modals/LicenseModal';
import { QrModal } from '../components/modals/QrModal';
import { ServerInfoModal } from '../components/modals/ServerInfoModal';
import { useLocalState } from '../states/LocalState';
import { LanguageContext } from './LanguageContext';
@ -47,7 +48,8 @@ export function ThemeContext({
modals={{
info: ServerInfoModal,
about: AboutInvenTreeModal,
license: LicenseModal
license: LicenseModal,
qr: QrModal
}}
>
<Notifications />

@ -1,12 +1,20 @@
import { t } from '@lingui/macro';
import type { SpotlightActionData } from '@mantine/spotlight';
import { IconLink, IconPointer } from '@tabler/icons-react';
import { IconBarcode, IconLink, IconPointer } from '@tabler/icons-react';
import type { NavigateFunction } from 'react-router-dom';
import { openContextModal } from '@mantine/modals';
import { useLocalState } from '../states/LocalState';
import { useUserState } from '../states/UserState';
import { aboutInvenTree, docLinks, licenseInfo, serverInfo } from './links';
export function openQrModal(navigate: NavigateFunction) {
return openContextModal({
modal: 'qr',
innerProps: { navigate: navigate }
});
}
export function getActions(navigate: NavigateFunction) {
const setNavigationOpen = useLocalState((state) => state.setNavigationOpen);
const { user } = useUserState();
@ -55,6 +63,13 @@ export function getActions(navigate: NavigateFunction) {
description: t`Open the main navigation menu`,
onClick: () => setNavigationOpen(true),
leftSection: <IconPointer size='1.2rem' />
},
{
id: 'scan',
label: t`Scan`,
description: t`Scan a barcode or QR code`,
onClick: () => openQrModal(navigate),
leftSection: <IconBarcode size='1.2rem' />
}
];

@ -20,7 +20,7 @@ export const statusCodeList: Record<string, ModelType> = {
/*
* Map the colors used in the backend to the colors used in the frontend
*/
export const colorMap: { [key: string]: string } = {
export const statusColorMap: { [key: string]: string } = {
dark: 'dark',
warning: 'yellow',
success: 'green',

@ -1,6 +1,12 @@
import { IconUsers } from '@tabler/icons-react';
import { useMemo, useState } from 'react';
import type { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
import type {
StatusCodeInterface,
StatusCodeListInterface
} from '../components/render/StatusRenderer';
import { useGlobalStatusState } from '../states/StatusState';
export function projectCodeFields(): ApiFormFieldSet {
return {
@ -12,16 +18,51 @@ export function projectCodeFields(): ApiFormFieldSet {
};
}
export function customStateFields(): ApiFormFieldSet {
export function useCustomStateFields(): ApiFormFieldSet {
// Status codes
const statusCodes = useGlobalStatusState();
// Selected base status class
const [statusClass, setStatusClass] = useState<string>('');
// Construct a list of status options based on the selected status class
const statusOptions: any[] = useMemo(() => {
const options: any[] = [];
const valuesList = Object.values(statusCodes.status ?? {}).find(
(value: StatusCodeListInterface) => value.status_class === statusClass
);
Object.values(valuesList?.values ?? {}).forEach(
(value: StatusCodeInterface) => {
options.push({
value: value.key,
display_name: value.label
});
}
);
return options;
}, [statusCodes, statusClass]);
return useMemo(() => {
return {
reference_status: {
onValueChange(value) {
setStatusClass(value);
}
},
logical_key: {
field_type: 'choice',
choices: statusOptions
},
key: {},
name: {},
label: {},
color: {},
logical_key: {},
model: {},
reference_status: {}
model: {}
};
}, [statusOptions]);
}
export function customUnitsFields(): ApiFormFieldSet {

@ -482,7 +482,7 @@ function StockOperationsRow({
const [statusOpen, statusHandlers] = useDisclosure(false, {
onOpen: () => {
setStatus(record?.status || undefined);
setStatus(record?.status_custom_key || record?.status || undefined);
props.changeFn(props.idx, 'status', record?.status || undefined);
},
onClose: () => {

@ -31,14 +31,18 @@ export default function useStatusCodes({
const statusCodeList = useGlobalStatusState.getState().status;
const codes = useMemo(() => {
const statusCodes = getStatusCodes(modelType) || {};
const statusCodes = getStatusCodes(modelType) || null;
const codesMap: Record<any, any> = {};
for (const name in statusCodes) {
codesMap[name] = statusCodes[name].key;
if (!statusCodes) {
return codesMap;
}
Object.keys(statusCodes.values).forEach((name) => {
codesMap[name] = statusCodes.values[name].key;
});
return codesMap;
}, [modelType, statusCodeList]);

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

Some files were not shown because too many files have changed in this diff Show More