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:
.github/workflows
RELEASE.mdcontrib
docs/docs
src
backend
InvenTree
InvenTree
build
common
config_template.yamlgeneric
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
src
components
contexts
defaults
forms
hooks
locales
ar
bg
cs
da
de
el
en
es
es_MX
et
fa
fi
fr
he
hi
hu
id
it
ja
ko
lt
lv
nl
no
pl
pt
pt_BR
ro
ru
sk
sl
sr
sv
th
tr
uk
vi
zh_Hans
zh_Hant
pages
build
company
part
purchasing
sales
stock
states
tables
tests
4
.github/workflows/docker.yaml
vendored
4
.github/workflows/docker.yaml
vendored
@ -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
|
||||
|
8
.github/workflows/qc_checks.yaml
vendored
8
.github/workflows/qc_checks.yaml
vendored
@ -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()
|
||||
|
23
RELEASE.md
23
RELEASE.md
@ -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(
|
||||
|
32
src/backend/InvenTree/common/migrations/0034_alter_inventreecustomuserstatemodel_unique_together_and_more.py
Normal file
32
src/backend/InvenTree/common/migrations/0034_alter_inventreecustomuserstatemodel_unique_together_and_more.py
Normal file
@ -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.
|
||||
|
41
src/backend/InvenTree/generic/states/serializers.py
Normal file
41
src/backend/InvenTree/generic/states/serializers.py
Normal file
@ -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)
|
||||
|
21
src/backend/InvenTree/generic/states/validators.py
Normal file
21
src/backend/InvenTree/generic/states/validators.py
Normal file
@ -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} />;
|
||||
}
|
||||
|
21
src/frontend/src/components/modals/QrModal.tsx
Normal file
21
src/frontend/src/components/modals/QrModal.tsx
Normal file
@ -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
Reference in New Issue
Block a user