mirror of
https://github.com/inventree/InvenTree.git
synced 2025-12-19 13:20:37 -06:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b6b41976f | ||
|
|
407ccb7bd2 | ||
|
|
d7ed114e2c | ||
|
|
c7a0265794 | ||
|
|
5bc56c826a | ||
|
|
1b42c00747 | ||
|
|
0f9bddbcd2 | ||
|
|
b0fc42d906 | ||
|
|
993849813f | ||
|
|
453c726d1e | ||
|
|
b6ca9ec6a4 | ||
|
|
de431b8e3d | ||
|
|
918adfb67f | ||
|
|
c89fe44fea | ||
|
|
8baafed49f | ||
|
|
abddfb0b04 | ||
|
|
4a3035ec85 | ||
|
|
0ebcff1a16 | ||
|
|
ee06e466ca | ||
|
|
7140ea8f43 | ||
|
|
a0d6ae2a54 | ||
|
|
74d8fe688e | ||
|
|
99f9a3271b | ||
|
|
295318ad18 | ||
|
|
c26f5bd74f | ||
|
|
4b564929d2 | ||
|
|
8576fbbade | ||
|
|
6045925ebe | ||
|
|
3715c42fed | ||
|
|
4c6e3490c0 | ||
|
|
91c095a011 | ||
|
|
d42e3087a8 | ||
|
|
4f7a12bd70 | ||
|
|
5048c1d667 | ||
|
|
0a522709b1 | ||
|
|
c32362456b | ||
|
|
4d07a49dfd | ||
|
|
ef5fd93207 | ||
|
|
df7204a334 | ||
|
|
a70382ac7a | ||
|
|
eed6223187 | ||
|
|
cab7a06146 | ||
|
|
40245a6c4a | ||
|
|
3cb806d20a | ||
|
|
8f1bf95463 | ||
|
|
4019dc9c9c | ||
|
|
70f17997eb | ||
|
|
2d773a7b3e | ||
|
|
39211ff4b6 | ||
|
|
e37ff5c3d5 | ||
|
|
6bd32c9236 | ||
|
|
04aec83e95 | ||
|
|
b57d035f7f | ||
|
|
3ac49441ca | ||
|
|
156c3cc9b2 | ||
|
|
52a26c9887 | ||
|
|
667e0a1bee |
7
.github/workflows/docker.yaml
vendored
7
.github/workflows/docker.yaml
vendored
@@ -127,7 +127,7 @@ jobs:
|
||||
uses: docker/setup-qemu-action@49b3bc8e6bdd4a60e6116a5414239cba5943d3cf # pin@v3.2.0
|
||||
- name: Set up Docker Buildx
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # pin@v3.7.1
|
||||
uses: docker/setup-buildx-action@6524bf65af31da8d45b59e8c27de4bd072b392f5 # pin@v3.8.0
|
||||
- name: Set up cosign
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # pin@v3.7.0
|
||||
@@ -163,11 +163,14 @@ jobs:
|
||||
inventree/inventree
|
||||
ghcr.io/${{ github.repository }}
|
||||
|
||||
- uses: depot/setup-action@v1
|
||||
|
||||
- name: Push Docker Images
|
||||
id: push-docker
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # pin@v6.10.0
|
||||
uses: depot/build-push-action@v1
|
||||
with:
|
||||
project: jczzbjkk68
|
||||
context: .
|
||||
file: ./contrib/container/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
4
.github/workflows/release.yaml
vendored
4
.github/workflows/release.yaml
vendored
@@ -49,7 +49,7 @@ jobs:
|
||||
- name: Build frontend
|
||||
run: cd src/frontend && npm run compile && npm run build
|
||||
- name: Create SBOM for frontend
|
||||
uses: anchore/sbom-action@55dc4ee22412511ee8c3142cbea40418e6cec693 # pin@v0
|
||||
uses: anchore/sbom-action@df80a981bc6edbc4e220a492d3cbe9f5547a6e75 # pin@v0
|
||||
with:
|
||||
artifact-name: frontend-build.spdx
|
||||
path: src/frontend
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
zip -r ../frontend-build.zip * .vite
|
||||
- name: Attest Build Provenance
|
||||
id: attest
|
||||
uses: actions/attest-build-provenance@c4fbc648846ca6f503a13a2281a5e7b98aa57202 # pin@v1
|
||||
uses: actions/attest-build-provenance@7668571508540a607bdfd90a87a560489fe372eb # pin@v1
|
||||
with:
|
||||
subject-path: "${{ github.workspace }}/src/backend/InvenTree/web/static/frontend-build.zip"
|
||||
|
||||
|
||||
2
.github/workflows/scorecard.yaml
vendored
2
.github/workflows/scorecard.yaml
vendored
@@ -67,6 +67,6 @@ jobs:
|
||||
|
||||
# Upload the results to GitHub's code scanning dashboard.
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@aa578102511db1f4524ed59b8cc2bae4f6e88195 # v3.27.6
|
||||
uses: github/codeql-action/upload-sarif@df409f7d9260372bd5f19e5b04e83cb3c43714ae # v3.27.9
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
2
.github/workflows/translations.yaml
vendored
2
.github/workflows/translations.yaml
vendored
@@ -51,7 +51,7 @@ jobs:
|
||||
git reset --hard
|
||||
git reset HEAD~
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@a9ffb7d5ac46eca1bb1f06656bf888b39462f161 # pin@v2
|
||||
uses: crowdin/github-action@8dfaf9c206381653e3767e3cb5ea5f08b45f02bf # pin@v2
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: false
|
||||
|
||||
@@ -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,29 @@
|
||||
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'
|
||||
# Ref: https://caddyserver.com/docs/json/apps/http/servers/trusted_proxies/
|
||||
# trusted_proxies ...
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ RUN apk add --no-cache \
|
||||
# Weasyprint requirements : https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#alpine-3-12
|
||||
py3-pip py3-pillow py3-cffi py3-brotli pango poppler-utils openldap \
|
||||
# Postgres client
|
||||
postgresql13-client \
|
||||
postgresql15-client \
|
||||
# MySQL / MariaDB client
|
||||
mariadb-client mariadb-connector-c \
|
||||
&& \
|
||||
|
||||
@@ -8,5 +8,5 @@ apk add gcc g++ musl-dev openssl-dev libffi-dev cargo python3-dev openldap-dev \
|
||||
jpeg-dev openjpeg-dev libwebp-dev zlib-dev \
|
||||
sqlite sqlite-dev \
|
||||
mariadb-connector-c-dev mariadb-client mariadb-dev \
|
||||
postgresql13-dev postgresql-libs \
|
||||
postgresql15-dev postgresql-libs \
|
||||
$@
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -26,7 +26,12 @@ Refer to the [invoke guide](./start/invoke.md#cant-find-any-collection-named-tas
|
||||
|
||||
If the installed version of invoke is too old, users may see error messages during the installation procedure. Refer to the [invoke guide](./start/invoke.md#minimum-version) for more information.
|
||||
|
||||
### No module named 'django'
|
||||
### INVE-E1 - No frontend included
|
||||
|
||||
Make sure you are running a stable or production release of InvenTree. The frontend panel is not included in development releases.
|
||||
More Information: [Error Codes - INVE-E1](./settings/error_codes.md#inve-e1)
|
||||
|
||||
### No module named <xxx>
|
||||
|
||||
During the install or update process, you may be presented with an error like:
|
||||
|
||||
@@ -44,6 +49,27 @@ This error occurs because your installed python version is not up to date. We [r
|
||||
|
||||
You (or your system administrator) needs to update python to meet the minimum requirements for InvenTree.
|
||||
|
||||
### InvenTree Site URL
|
||||
|
||||
During the installation or update process, you may see an error similar to:
|
||||
|
||||
```
|
||||
'No CSRF_TRUSTED_ORIGINS specified. Please provide a list of trusted origins, or specify INVENTREE_SITE_URL'
|
||||
```
|
||||
|
||||
If you see this error, it means that the `INVENTREE_SITE_URL` environment variable has not correctly specified. Refer to the [configuration documentation](./start/config.md#site-url) for more information.
|
||||
|
||||
### Login Issues
|
||||
|
||||
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.
|
||||
|
||||
@@ -154,14 +154,14 @@ def on_config(config, *args, **kwargs):
|
||||
|
||||
# Check for 'versions.json' file
|
||||
# If it does not exist, we need to fetch it from the RTD API
|
||||
if os.path.exists(os.path.join(os.path.dirname(__file__), 'versions.json')):
|
||||
print("Found 'versions.json' file")
|
||||
else:
|
||||
fetch_rtd_versions()
|
||||
# if os.path.exists(os.path.join(os.path.dirname(__file__), 'versions.json')):
|
||||
# print("Found 'versions.json' file")
|
||||
# else:
|
||||
# fetch_rtd_versions()
|
||||
|
||||
if rtd:
|
||||
rtd_version = os.environ['READTHEDOCS_VERSION']
|
||||
rtd_language = os.environ['READTHEDOCS_LANGUAGE']
|
||||
rtd_version = os.environ.get('READTHEDOCS_VERSION')
|
||||
rtd_language = os.environ.get('READTHEDOCS_LANGUAGE')
|
||||
|
||||
site_url = f'https://docs.inventree.org/{rtd_language}/{rtd_version}'
|
||||
assets_dir = f'/{rtd_language}/{rtd_version}/assets'
|
||||
|
||||
26
docs/docs/settings/error_codes.md
Normal file
26
docs/docs/settings/error_codes.md
Normal file
@@ -0,0 +1,26 @@
|
||||
## Error Codes
|
||||
|
||||
InvenTree is starting to use error codes to help identify and diagnose issues. These are increasingly being added to the codebase. Error messages missing an error code should be reported on GitHub.
|
||||
Error codes are prefixed with `INVE-` and are followed by a letter to indicate the type of error and a number to indicate the specific error. Once a code is used it might not be reassigned to a different error, it can be marked as stricken from the list.
|
||||
|
||||
### INVE-E (InvenTree Error)
|
||||
Errors - These are critical errors which should be addressed as soon as possible.
|
||||
|
||||
#### INVE-E1
|
||||
**No frontend included - Backend/web**
|
||||
|
||||
Only stable / production releases of InvenTree include the frontend panel. This is both a measure of resource-saving and attack surface reduction. If you want to use the frontend panel, you can either:″
|
||||
- use a docker image that is version-tagged or the stable version
|
||||
- use a package version that is from the stable or version stream
|
||||
- install node and yarn on the server to build the frontend with the [invoke](../start/invoke.md) task `int.frontend-build`
|
||||
|
||||
Raise an issue if none of these options work.
|
||||
|
||||
### INVE-W (InvenTree Warning)
|
||||
Warnings - These are non-critical errors which should be addressed when possible.
|
||||
|
||||
### INVE-I (InvenTree Information)
|
||||
Information — These are not errors but information messages. They might point out potential issues or just provide information.
|
||||
|
||||
### INVE-M (InvenTree Miscellaneous)
|
||||
Miscellaneous — These are information messages that might be used to mark debug information or other messages helpful for the InvenTree team to understand behaviour.
|
||||
@@ -61,13 +61,6 @@ The following basic options are available:
|
||||
| Environment Variable | Configuration File | Description | Default |
|
||||
| --- | --- | --- | --- |
|
||||
| INVENTREE_SITE_URL | site_url | Specify a fixed site URL | *Not specified* |
|
||||
| INVENTREE_DEBUG | debug | Enable [debug mode](./intro.md#debug-mode) | True |
|
||||
| INVENTREE_DEBUG_QUERYCOUNT | debug_querycount | Enable [query count logging](https://github.com/bradmontgomery/django-querycount) in the terminal | False |
|
||||
| INVENTREE_DEBUG_SHELL | debug_shell | Enable [administrator shell](https://github.com/djk2/django-admin-shell) (only in debug mode) | False |
|
||||
| INVENTREE_LOG_LEVEL | log_level | Set level of logging to terminal | WARNING |
|
||||
| INVENTREE_JSON_LOG | json_log | log as json | False |
|
||||
| INVENTREE_DB_LOGGING | db_logging | Enable logging of database messages | False |
|
||||
| INVENTREE_WRITE_LOG | write_log | Enable writing of log messages to file at config base | False |
|
||||
| INVENTREE_TIMEZONE | timezone | Server timezone | UTC |
|
||||
| INVENTREE_ADMIN_ENABLED | admin_enabled | Enable the [django administrator interface]({% include "django.html" %}/ref/contrib/admin/) | True |
|
||||
| INVENTREE_ADMIN_URL | admin_url | URL for accessing [admin interface](../settings/admin.md) | admin |
|
||||
@@ -78,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.
|
||||
@@ -90,6 +85,36 @@ By default, the InvenTree server will not automatically apply database migration
|
||||
|
||||
With "auto update" enabled, the InvenTree server will automatically apply database migrations as required. To enable automatic database updates, set `INVENTREE_AUTO_UPDATE` to `True`.
|
||||
|
||||
## Debugging and Logging Options
|
||||
|
||||
The following debugging / logging options are available:
|
||||
|
||||
| Environment Variable | Configuration File | Description | Default |
|
||||
| --- | --- | --- | --- |
|
||||
| INVENTREE_DEBUG | debug | Enable [debug mode](./intro.md#debug-mode) | False |
|
||||
| INVENTREE_DEBUG_QUERYCOUNT | debug_querycount | Enable [query count logging](https://github.com/bradmontgomery/django-querycount) in the terminal | False |
|
||||
| INVENTREE_DEBUG_SHELL | debug_shell | Enable [administrator shell](https://github.com/djk2/django-admin-shell) (only in debug mode) | False |
|
||||
| INVENTREE_DB_LOGGING | db_logging | Enable logging of database messages | False |
|
||||
| INVENTREE_LOG_LEVEL | log_level | Set level of logging to terminal | WARNING |
|
||||
| INVENTREE_JSON_LOG | json_log | log as json | False |
|
||||
| INVENTREE_WRITE_LOG | write_log | Enable writing of log messages to file at config base | False |
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enabling the `INVENTREE_DEBUG` setting will turn on [Django debug mode]({% include "django.html" %}/ref/settings/#debug). This mode is intended for development purposes, and should not be enabled in a production environment. Read more about [InvenTree debug mode](./intro.md#debug-mode).
|
||||
|
||||
### Query Count Logging
|
||||
|
||||
Enabling the `INVENTREE_DEBUG_QUERYCOUNT` setting will log the number of database queries executed for each page load. This can be useful for identifying performance bottlenecks in the InvenTree server. Note that this setting is only available if `INVENTREE_DEBUG` is also enabled.
|
||||
|
||||
### Debug Shell
|
||||
|
||||
Enabling the `INVENTREE_DEBUG_SHELL` setting will allow the use of the [administrator shell](https://github.com/djk2/django-admin-shell). Note that this setting is only available if `INVENTREE_DEBUG` is also enabled, and is only accessible to superuser accounts.
|
||||
|
||||
### Database Logging
|
||||
|
||||
Enabling the `INVENTREE_DB_LOGGING` setting will log all database queries to the terminal. This can be useful for debugging database-related issues.
|
||||
|
||||
## Server Access
|
||||
|
||||
Depending on how your InvenTree installation is configured, you will need to pay careful attention to the following settings. If you are running your server behind a proxy, or want to adjust support for [CORS requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS), one or more of the following settings may need to be adjusted.
|
||||
@@ -116,12 +141,13 @@ 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 |
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Note that in [debug mode](./intro.md#debug-mode), some of the above settings are automatically adjusted to allow for easier development:
|
||||
Note that in [debug mode](./intro.md#debug-mode), some of the above settings are automatically adjusted to allow for easier development. The following settings are internally overridden in debug mode with the values specified below:
|
||||
|
||||
| Setting | Value in Debug Mode | Description |
|
||||
| --- | --- | --- |
|
||||
@@ -130,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
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ InvenTree run-time configuration options described in the [configuration documen
|
||||
|
||||
As docker containers are ephemeral, any *persistent* data must be stored in an external [volume](https://docs.docker.com/storage/volumes/). To simplify installation / implementation, all external data are stored in a single volume, arranged as follows:
|
||||
|
||||
#### Media FIles
|
||||
#### Media Files
|
||||
|
||||
Uploaded media files are stored in the `media/` subdirectory of the external data volume.
|
||||
|
||||
@@ -112,6 +112,13 @@ InvenTree stores any persistent data (e.g. uploaded media files, database data,
|
||||
!!! info "Data Directory"
|
||||
Make sure you change the path to the local directory where you want persistent data to be stored.
|
||||
|
||||
#### Database Connection
|
||||
|
||||
The `inventree-db` container is configured to use the `postgres:13` docker image. The `inventree-server` and `inventree-worker` containers support connection to a postgres database up to (and including) version 15.
|
||||
|
||||
!!! warning "Newer Postgres Versions"
|
||||
The InvenTree docker image supports connection to a postgres database up to version 15. Connecting to a database using a newer version of postgres is not possible.
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Volume Mapping
|
||||
|
||||
@@ -23,6 +23,10 @@ The following guide provides a streamlined production InvenTree installation, wi
|
||||
!!! warning "Docker Knowledge Required"
|
||||
This guide assumes that you are reasonably comfortable with the basic concepts of docker and docker compose.
|
||||
|
||||
### Frequently Asked Questions
|
||||
|
||||
If you encounter any issues during the installation process, please refer first to the [FAQ](../faq.md) for common problems and solutions.
|
||||
|
||||
## Docker Installation
|
||||
|
||||
### Required Files
|
||||
|
||||
@@ -23,6 +23,10 @@ The above command may need to be run with `sudo` permissions, depending on the s
|
||||
sudo wget -qO install.sh https://get.inventree.org && sudo bash install.sh
|
||||
```
|
||||
|
||||
#### Frequently Asked Questions
|
||||
|
||||
If you encounter any issues during the installation process, please refer first to the [FAQ](../faq.md) for common problems and solutions.
|
||||
|
||||
### File Locations
|
||||
|
||||
The installer creates the following directories:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
42
docs/main.py
42
docs/main.py
@@ -8,6 +8,26 @@ import textwrap
|
||||
import requests
|
||||
import yaml
|
||||
|
||||
# Debugging output - useful for diagnosing CI build issues
|
||||
print('loading ./docs/main.py...')
|
||||
|
||||
# Print out some useful debugging information
|
||||
# Ref: https://docs.readthedocs.io/en/stable/reference/environment-variables.html
|
||||
for key in [
|
||||
'GITHUB_ACTIONS',
|
||||
'GITHUB_REF',
|
||||
'READTHEDOCS',
|
||||
'READTHEDOCS_GIT_IDENTIFIER',
|
||||
'READTHEDOCS_GIT_CLONE_URL',
|
||||
'READTHEDOCS_GIT_COMMIT_HASH',
|
||||
'READTHEDOCS_PROJECT',
|
||||
'READTHEDOCS_VERSION',
|
||||
'READTHEDOCS_VERSION_NAME',
|
||||
'READTHEDOCS_VERSION_TYPE',
|
||||
]:
|
||||
val = os.environ.get(key, None) or '-- MISSING --'
|
||||
print(f' - {key}: {val}')
|
||||
|
||||
# Cached settings dict values
|
||||
global GLOBAL_SETTINGS
|
||||
global USER_SETTINGS
|
||||
@@ -77,12 +97,15 @@ def get_build_enviroment() -> str:
|
||||
"""Returns the branch we are currently building on, based on the environment variables of the various CI platforms."""
|
||||
# Check if we are in ReadTheDocs
|
||||
if os.environ.get('READTHEDOCS') == 'True':
|
||||
return os.environ.get('READTHEDOCS_GIT_IDENTIFIER')
|
||||
for var in ['READTHEDOCS_GIT_COMMIT_HASH', 'READTHEDOCS_GIT_IDENTIFIER']:
|
||||
if val := os.environ.get(var):
|
||||
return val
|
||||
# We are in GitHub Actions
|
||||
elif os.environ.get('GITHUB_ACTIONS') == 'true':
|
||||
return os.environ.get('GITHUB_REF')
|
||||
else:
|
||||
return 'master'
|
||||
|
||||
# Default to 'master' branch
|
||||
return 'master'
|
||||
|
||||
|
||||
def define_env(env):
|
||||
@@ -243,14 +266,19 @@ def define_env(env):
|
||||
return includefile(fn, f'Template: {base}', fmt='html')
|
||||
|
||||
@env.macro
|
||||
def rendersetting(setting: dict):
|
||||
def rendersetting(key: str, setting: dict):
|
||||
"""Render a provided setting object into a table row."""
|
||||
name = setting['name']
|
||||
description = setting['description']
|
||||
default = setting.get('default')
|
||||
units = setting.get('units')
|
||||
|
||||
return f'| {name} | {description} | {default if default is not None else ""} | {units if units is not None else ""} |'
|
||||
default = f'`{default}`' if default else ''
|
||||
units = f'`{units}`' if units else ''
|
||||
|
||||
return (
|
||||
f'| <div title="{key}">{name}</div> | {description} | {default} | {units} |'
|
||||
)
|
||||
|
||||
@env.macro
|
||||
def globalsetting(key: str):
|
||||
@@ -262,7 +290,7 @@ def define_env(env):
|
||||
global GLOBAL_SETTINGS
|
||||
setting = GLOBAL_SETTINGS[key]
|
||||
|
||||
return rendersetting(setting)
|
||||
return rendersetting(key, setting)
|
||||
|
||||
@env.macro
|
||||
def usersetting(key: str):
|
||||
@@ -274,4 +302,4 @@ def define_env(env):
|
||||
global USER_SETTINGS
|
||||
setting = USER_SETTINGS[key]
|
||||
|
||||
return rendersetting(setting)
|
||||
return rendersetting(key, setting)
|
||||
|
||||
@@ -153,15 +153,18 @@ nav:
|
||||
- User Settings: settings/user.md
|
||||
- Reference Patterns: settings/reference.md
|
||||
- Admin Interface: settings/admin.md
|
||||
- User Permissions: settings/permissions.md
|
||||
- Single Sign on: settings/SSO.md
|
||||
- Multi Factor Authentication: settings/MFA.md
|
||||
- Setup:
|
||||
- User Permissions: settings/permissions.md
|
||||
- Single Sign on: settings/SSO.md
|
||||
- Multi Factor Authentication: settings/MFA.md
|
||||
- Email: settings/email.md
|
||||
- Currency Support: settings/currency.md
|
||||
- Export Data: settings/export.md
|
||||
- Import Data: settings/import.md
|
||||
- Error Logs: settings/logs.md
|
||||
- Email: settings/email.md
|
||||
- Background Tasks: settings/tasks.md
|
||||
- Currency Support: settings/currency.md
|
||||
- Operations:
|
||||
- Background Tasks: settings/tasks.md
|
||||
- Error Logs: settings/logs.md
|
||||
- Error Codes: settings/error_codes.md
|
||||
- App:
|
||||
- InvenTree App: app/app.md
|
||||
- Connect: app/connect.md
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
},
|
||||
{
|
||||
"pattern": "https://opensource.org/license/MIT"
|
||||
},
|
||||
{
|
||||
"pattern": "https://docs.inventree.org/en/latest/credits/"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -319,9 +319,9 @@ mkdocs-macros-plugin==1.3.7 \
|
||||
--hash=sha256:02432033a5b77fb247d6ec7924e72fc4ceec264165b1644ab8d0dc159c22ce59 \
|
||||
--hash=sha256:17c7fd1a49b94defcdb502fd453d17a1e730f8836523379d21292eb2be4cb523
|
||||
# via -r docs/requirements.in
|
||||
mkdocs-material==9.5.48 \
|
||||
--hash=sha256:a582531e8b34f4c7ed38c29d5c44763053832cf2a32f7409567e0c74749a47db \
|
||||
--hash=sha256:b695c998f4b939ce748adbc0d3bff73fa886a670ece948cf27818fa115dc16f8
|
||||
mkdocs-material==9.5.49 \
|
||||
--hash=sha256:3671bb282b4f53a1c72e08adbe04d2481a98f85fed392530051f80ff94a9621d \
|
||||
--hash=sha256:c3c2d8176b18198435d3a3e119011922f3e11424074645c24019c2dcf08a360e
|
||||
# via -r docs/requirements.in
|
||||
mkdocs-material-extensions==1.3.1 \
|
||||
--hash=sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443 \
|
||||
|
||||
2
src/backend/InvenTree/.gitignore
vendored
Normal file
2
src/backend/InvenTree/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Files generated during unit testing
|
||||
_testfolder/
|
||||
@@ -1,13 +1,16 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 293
|
||||
INVENTREE_API_VERSION = 294
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v294 - 2024-12-23 : https://github.com/inventree/InvenTree/pull/8738
|
||||
- Extends registration API documentation
|
||||
|
||||
v293 - 2024-12-14 : https://github.com/inventree/InvenTree/pull/8658
|
||||
- Adds new fields to the supplier barcode API endpoints
|
||||
|
||||
|
||||
40
src/backend/InvenTree/InvenTree/auth_override_views.py
Normal file
40
src/backend/InvenTree/InvenTree/auth_override_views.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Overrides for registration view."""
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from allauth.account import app_settings as allauth_account_settings
|
||||
from dj_rest_auth.app_settings import api_settings
|
||||
from dj_rest_auth.registration.views import RegisterView
|
||||
|
||||
|
||||
class CustomRegisterView(RegisterView):
|
||||
"""Registers a new user.
|
||||
|
||||
Accepts the following POST parameters: username, email, password1, password2.
|
||||
"""
|
||||
|
||||
# Fixes https://github.com/inventree/InvenTree/issues/8707
|
||||
# This contains code from dj-rest-auth 7.0 - therefore the version was pinned
|
||||
def get_response_data(self, user):
|
||||
"""Override to fix check for auth_model."""
|
||||
if (
|
||||
allauth_account_settings.EMAIL_VERIFICATION
|
||||
== allauth_account_settings.EmailVerificationMethod.MANDATORY
|
||||
):
|
||||
return {'detail': _('Verification e-mail sent.')}
|
||||
|
||||
if api_settings.USE_JWT:
|
||||
data = {
|
||||
'user': user,
|
||||
'access': self.access_token,
|
||||
'refresh': self.refresh_token,
|
||||
}
|
||||
return api_settings.JWT_SERIALIZER(
|
||||
data, context=self.get_serializer_context()
|
||||
).data
|
||||
elif self.token_model:
|
||||
# Only change in this block is below
|
||||
return api_settings.TOKEN_SERIALIZER(
|
||||
user.api_tokens.last(), context=self.get_serializer_context()
|
||||
).data
|
||||
return None
|
||||
@@ -20,7 +20,9 @@ from allauth_2fa.utils import user_has_valid_totp_device
|
||||
from crispy_forms.bootstrap import AppendedText, PrependedAppendedText, PrependedText
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Field, Layout
|
||||
from dj_rest_auth.registration.serializers import RegisterSerializer
|
||||
from dj_rest_auth.registration.serializers import (
|
||||
RegisterSerializer as DjRestRegisterSerializer,
|
||||
)
|
||||
from rest_framework import serializers
|
||||
|
||||
import InvenTree.helpers_model
|
||||
@@ -385,16 +387,11 @@ class CustomSocialAccountAdapter(
|
||||
|
||||
|
||||
# override dj-rest-auth
|
||||
class CustomRegisterSerializer(RegisterSerializer):
|
||||
"""Override of serializer to use dynamic settings."""
|
||||
class RegisterSerializer(DjRestRegisterSerializer):
|
||||
"""Registration requires email, password (twice) and username."""
|
||||
|
||||
email = serializers.EmailField()
|
||||
|
||||
def __init__(self, instance=None, data=..., **kwargs):
|
||||
"""Check settings to influence which fields are needed."""
|
||||
kwargs['email_required'] = get_global_setting('LOGIN_MAIL_REQUIRED')
|
||||
super().__init__(instance, data, **kwargs)
|
||||
|
||||
def save(self, request):
|
||||
"""Override to check if registration is open."""
|
||||
if registration_enabled():
|
||||
|
||||
@@ -620,12 +620,10 @@ REST_AUTH = {
|
||||
'TOKEN_MODEL': 'users.models.ApiToken',
|
||||
'TOKEN_CREATOR': 'users.models.default_create_token',
|
||||
'USE_JWT': USE_JWT,
|
||||
'REGISTER_SERIALIZER': 'InvenTree.forms.RegisterSerializer',
|
||||
}
|
||||
|
||||
OLD_PASSWORD_FIELD_ENABLED = True
|
||||
REST_AUTH_REGISTER_SERIALIZERS = {
|
||||
'REGISTER_SERIALIZER': 'InvenTree.forms.CustomRegisterSerializer'
|
||||
}
|
||||
|
||||
# JWT settings - rest_framework_simplejwt
|
||||
if USE_JWT:
|
||||
@@ -1106,6 +1104,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)
|
||||
|
||||
@@ -1220,10 +1224,24 @@ SESSION_COOKIE_SECURE = (
|
||||
if DEBUG
|
||||
else (
|
||||
SESSION_COOKIE_SAMESITE == 'None'
|
||||
or get_boolean_setting('INVENTREE_SESSION_COOKIE_SECURE', 'cookie.secure', True)
|
||||
or get_boolean_setting(
|
||||
'INVENTREE_SESSION_COOKIE_SECURE', 'cookie.secure', False
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# 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',
|
||||
@@ -1252,7 +1270,7 @@ CORS_ALLOW_CREDENTIALS = get_boolean_setting(
|
||||
)
|
||||
|
||||
# Only allow CORS access to the following URL endpoints
|
||||
CORS_URLS_REGEX = r'^/(api|auth|media|static)/.*$'
|
||||
CORS_URLS_REGEX = r'^/(api|auth|media|plugin|static)/.*$'
|
||||
|
||||
CORS_ALLOWED_ORIGINS = get_setting(
|
||||
'INVENTREE_CORS_ORIGIN_WHITELIST',
|
||||
|
||||
@@ -18,6 +18,7 @@ from rest_framework.response import Response
|
||||
|
||||
import InvenTree.sso
|
||||
from common.settings import get_global_setting
|
||||
from InvenTree.forms import registration_enabled
|
||||
from InvenTree.mixins import CreateAPI, ListAPI, ListCreateAPI
|
||||
from InvenTree.serializers import EmptySerializer, InvenTreeModelSerializer
|
||||
|
||||
@@ -204,7 +205,7 @@ class SocialProviderListView(ListAPI):
|
||||
and get_global_setting('LOGIN_ENFORCE_MFA'),
|
||||
'mfa_enabled': settings.MFA_ENABLED,
|
||||
'providers': provider_list,
|
||||
'registration_enabled': get_global_setting('LOGIN_ENABLE_REG'),
|
||||
'registration_enabled': registration_enabled(),
|
||||
'password_forgotten_enabled': get_global_setting('LOGIN_ENABLE_PWD_FORGOT'),
|
||||
}
|
||||
return Response(data)
|
||||
|
||||
@@ -463,7 +463,7 @@ def get_user_color_theme(user):
|
||||
user_theme_name = user_theme.name
|
||||
if not user_theme_name or not ColorTheme.is_valid_choice(user_theme):
|
||||
user_theme_name = 'default'
|
||||
except ColorTheme.DoesNotExist:
|
||||
except Exception:
|
||||
user_theme_name = 'default'
|
||||
|
||||
return user_theme_name
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Test the sso module functionality."""
|
||||
"""Test the sso and auth module functionality."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Group, User
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import override_settings
|
||||
from django.test.testcases import TransactionTestCase
|
||||
|
||||
@@ -9,6 +11,7 @@ from allauth.socialaccount.models import SocialAccount, SocialLogin
|
||||
from common.models import InvenTreeSetting
|
||||
from InvenTree import sso
|
||||
from InvenTree.forms import RegistratonMixin
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||
|
||||
|
||||
class Dummy:
|
||||
@@ -119,3 +122,90 @@ class TestSsoGroupSync(TransactionTestCase):
|
||||
self.assertEqual(Group.objects.filter(name='inventree_group').count(), 0)
|
||||
sso.ensure_sso_groups(None, self.sociallogin)
|
||||
self.assertEqual(Group.objects.filter(name='inventree_group').count(), 1)
|
||||
|
||||
|
||||
class EmailSettingsContext:
|
||||
"""Context manager to enable email settings for tests."""
|
||||
|
||||
def __enter__(self):
|
||||
"""Enable stuff."""
|
||||
InvenTreeSetting.set_setting('LOGIN_ENABLE_REG', True)
|
||||
settings.EMAIL_HOST = 'localhost'
|
||||
|
||||
def __exit__(self, type, value, traceback):
|
||||
"""Exit stuff."""
|
||||
InvenTreeSetting.set_setting('LOGIN_ENABLE_REG', False)
|
||||
settings.EMAIL_HOST = ''
|
||||
|
||||
|
||||
class TestAuth(InvenTreeAPITestCase):
|
||||
"""Test authentication functionality."""
|
||||
|
||||
def email_args(self, user=None, email=None):
|
||||
"""Generate registration arguments."""
|
||||
return {
|
||||
'username': user or 'user1',
|
||||
'email': email or 'test@example.com',
|
||||
'password1': '#asdf1234',
|
||||
'password2': '#asdf1234',
|
||||
}
|
||||
|
||||
def test_registration(self):
|
||||
"""Test the registration process."""
|
||||
self.logout()
|
||||
|
||||
# Duplicate username
|
||||
resp = self.post(
|
||||
'/api/auth/registration/',
|
||||
self.email_args(user='testuser'),
|
||||
expected_code=400,
|
||||
)
|
||||
self.assertIn(
|
||||
'A user with that username already exists.', resp.data['username']
|
||||
)
|
||||
|
||||
# Registration is disabled
|
||||
resp = self.post(
|
||||
'/api/auth/registration/', self.email_args(), expected_code=400
|
||||
)
|
||||
self.assertIn('Registration is disabled.', resp.data['non_field_errors'])
|
||||
|
||||
# Enable registration - now it should work
|
||||
with EmailSettingsContext():
|
||||
resp = self.post(
|
||||
'/api/auth/registration/', self.email_args(), expected_code=201
|
||||
)
|
||||
self.assertIn('key', resp.data)
|
||||
|
||||
def test_registration_email(self):
|
||||
"""Test that LOGIN_SIGNUP_MAIL_RESTRICTION works."""
|
||||
self.logout()
|
||||
|
||||
# Check the setting validation is working
|
||||
with self.assertRaises(ValidationError):
|
||||
InvenTreeSetting.set_setting(
|
||||
'LOGIN_SIGNUP_MAIL_RESTRICTION', 'example.com,inventree.org'
|
||||
)
|
||||
|
||||
# Setting setting correctly
|
||||
correct_setting = '@example.com,@inventree.org'
|
||||
InvenTreeSetting.set_setting('LOGIN_SIGNUP_MAIL_RESTRICTION', correct_setting)
|
||||
self.assertEqual(
|
||||
InvenTreeSetting.get_setting('LOGIN_SIGNUP_MAIL_RESTRICTION'),
|
||||
correct_setting,
|
||||
)
|
||||
|
||||
# Wrong email format
|
||||
resp = self.post(
|
||||
'/api/auth/registration/',
|
||||
self.email_args(email='admin@invenhost.com'),
|
||||
expected_code=400,
|
||||
)
|
||||
self.assertIn('The provided email domain is not approved.', resp.data['email'])
|
||||
|
||||
# Right format should work
|
||||
with EmailSettingsContext():
|
||||
resp = self.post(
|
||||
'/api/auth/registration/', self.email_args(), expected_code=201
|
||||
)
|
||||
self.assertIn('key', resp.data)
|
||||
@@ -32,6 +32,7 @@ import users.api
|
||||
from build.urls import build_urls
|
||||
from common.urls import common_urls
|
||||
from company.urls import company_urls, manufacturer_part_urls, supplier_part_urls
|
||||
from InvenTree.auth_override_views import CustomRegisterView
|
||||
from order.urls import order_urls
|
||||
from part.urls import part_urls
|
||||
from plugin.urls import get_plugin_urls
|
||||
@@ -202,6 +203,7 @@ apipatterns = [
|
||||
ConfirmEmailView.as_view(),
|
||||
name='account_confirm_email',
|
||||
),
|
||||
path('registration/', CustomRegisterView.as_view(), name='rest_register'),
|
||||
path('registration/', include('dj_rest_auth.registration.urls')),
|
||||
path(
|
||||
'providers/', SocialProviderListView.as_view(), name='social_providers'
|
||||
|
||||
@@ -18,7 +18,7 @@ from django.conf import settings
|
||||
from .api_version import INVENTREE_API_TEXT, INVENTREE_API_VERSION
|
||||
|
||||
# InvenTree software version
|
||||
INVENTREE_SW_VERSION = '0.17.0 dev'
|
||||
INVENTREE_SW_VERSION = '0.17.6'
|
||||
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
@@ -165,15 +165,6 @@ class BaseInvenTreeSetting(models.Model):
|
||||
|
||||
If a particular setting is not present, create it with the default value
|
||||
"""
|
||||
cache_key = f'BUILD_DEFAULT_VALUES:{cls.__name__!s}'
|
||||
|
||||
try:
|
||||
if InvenTree.helpers.str2bool(cache.get(cache_key, False)):
|
||||
# Already built default values
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
existing_keys = cls.objects.filter(**kwargs).values_list('key', flat=True)
|
||||
settings_keys = cls.SETTINGS.keys()
|
||||
@@ -194,11 +185,6 @@ class BaseInvenTreeSetting(models.Model):
|
||||
'Failed to build default values for %s (%s)', str(cls), str(type(exc))
|
||||
)
|
||||
|
||||
try:
|
||||
cache.set(cache_key, True, timeout=3600)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _call_settings_function(self, reference: str, args, kwargs):
|
||||
"""Call a function associated with a particular setting.
|
||||
|
||||
|
||||
@@ -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: True
|
||||
|
||||
@@ -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,19 +117,16 @@ 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:
|
||||
# secure: false
|
||||
# samesite: false
|
||||
cookie:
|
||||
secure: false
|
||||
samesite: false
|
||||
|
||||
# Cross Origin Resource Sharing (CORS) settings (see https://github.com/adamchainz/django-cors-headers)
|
||||
cors:
|
||||
@@ -160,7 +160,6 @@ cache:
|
||||
host: 'inventree-cache'
|
||||
port: 6379
|
||||
|
||||
|
||||
# Login configuration
|
||||
login_confirm_days: 3
|
||||
login_attempts: 5
|
||||
|
||||
@@ -100,7 +100,9 @@ class InvenTreeCustomStatusModelField(models.PositiveIntegerField):
|
||||
"""Add the _custom_key field to the model."""
|
||||
cls._meta.supports_custom_status = True
|
||||
|
||||
if not hasattr(self, '_custom_key_field'):
|
||||
if not hasattr(self, '_custom_key_field') and not hasattr(
|
||||
cls, f'{name}_custom_key'
|
||||
):
|
||||
self.add_field(cls, name)
|
||||
|
||||
super().contribute_to_class(cls, name)
|
||||
|
||||
@@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import tablib
|
||||
from rest_framework import fields, serializers
|
||||
from taggit.serializers import TagListSerializerField
|
||||
|
||||
import importer.operations
|
||||
from InvenTree.helpers import DownloadFile, GetExportFormats, current_date
|
||||
@@ -81,6 +82,11 @@ class DataImportSerializerMixin:
|
||||
if issubclass(field.__class__, fields.FileField):
|
||||
continue
|
||||
|
||||
# Skip tags fields
|
||||
# TODO: Implement tag field support
|
||||
if issubclass(field.__class__, TagListSerializerField):
|
||||
continue
|
||||
|
||||
importable_fields[name] = field
|
||||
|
||||
return importable_fields
|
||||
|
||||
@@ -111,17 +111,13 @@ class DataImportSession(models.Model):
|
||||
)
|
||||
|
||||
@property
|
||||
def field_mapping(self):
|
||||
def field_mapping(self) -> dict:
|
||||
"""Construct a dict of field mappings for this import session.
|
||||
|
||||
Returns: A dict of field: column mappings
|
||||
Returns:
|
||||
A dict of field -> column mappings
|
||||
"""
|
||||
mapping = {}
|
||||
|
||||
for i in self.column_mappings.all():
|
||||
mapping[i.field] = i.column
|
||||
|
||||
return mapping
|
||||
return {mapping.field: mapping.column for mapping in self.column_mappings.all()}
|
||||
|
||||
@property
|
||||
def model_class(self):
|
||||
@@ -138,7 +134,7 @@ class DataImportSession(models.Model):
|
||||
|
||||
return supported_models().get(self.model_type, None)
|
||||
|
||||
def extract_columns(self):
|
||||
def extract_columns(self) -> None:
|
||||
"""Run initial column extraction and mapping.
|
||||
|
||||
This method is called when the import session is first created.
|
||||
@@ -204,7 +200,7 @@ class DataImportSession(models.Model):
|
||||
self.status = DataImportStatusCode.MAPPING.value
|
||||
self.save()
|
||||
|
||||
def accept_mapping(self):
|
||||
def accept_mapping(self) -> None:
|
||||
"""Accept current mapping configuration.
|
||||
|
||||
- Validate that the current column mapping is correct
|
||||
@@ -243,7 +239,7 @@ class DataImportSession(models.Model):
|
||||
# No errors, so trigger the data import process
|
||||
self.trigger_data_import()
|
||||
|
||||
def trigger_data_import(self):
|
||||
def trigger_data_import(self) -> None:
|
||||
"""Trigger the data import process for this session.
|
||||
|
||||
Offloads the task to the background worker process.
|
||||
@@ -256,7 +252,7 @@ class DataImportSession(models.Model):
|
||||
|
||||
offload_task(importer.tasks.import_data, self.pk)
|
||||
|
||||
def import_data(self):
|
||||
def import_data(self) -> None:
|
||||
"""Perform the data import process for this session."""
|
||||
# Clear any existing data rows
|
||||
self.rows.all().delete()
|
||||
@@ -316,12 +312,12 @@ class DataImportSession(models.Model):
|
||||
return True
|
||||
|
||||
@property
|
||||
def row_count(self):
|
||||
def row_count(self) -> int:
|
||||
"""Return the number of rows in the import session."""
|
||||
return self.rows.count()
|
||||
|
||||
@property
|
||||
def completed_row_count(self):
|
||||
def completed_row_count(self) -> int:
|
||||
"""Return the number of completed rows for this session."""
|
||||
return self.rows.filter(complete=True).count()
|
||||
|
||||
@@ -349,7 +345,7 @@ class DataImportSession(models.Model):
|
||||
self._available_fields = fields
|
||||
return fields
|
||||
|
||||
def required_fields(self):
|
||||
def required_fields(self) -> dict:
|
||||
"""Returns information on which fields are *required* for import."""
|
||||
fields = self.available_fields()
|
||||
|
||||
@@ -591,7 +587,7 @@ class DataImportRow(models.Model):
|
||||
value = value or None
|
||||
|
||||
# Use the default value, if provided
|
||||
if value in [None, ''] and field in default_values:
|
||||
if value is None and field in default_values:
|
||||
value = default_values[field]
|
||||
|
||||
data[field] = value
|
||||
@@ -607,7 +603,9 @@ class DataImportRow(models.Model):
|
||||
- If available, we use the "default" values provided by the import session
|
||||
- If available, we use the "override" values provided by the import session
|
||||
"""
|
||||
data = self.default_values
|
||||
data = {}
|
||||
|
||||
data.update(self.default_values)
|
||||
|
||||
if self.data:
|
||||
data.update(self.data)
|
||||
|
||||
@@ -81,23 +81,6 @@ def extract_column_names(data_file) -> list:
|
||||
return headers
|
||||
|
||||
|
||||
def extract_rows(data_file) -> list:
|
||||
"""Extract rows from the data file.
|
||||
|
||||
Each returned row is a dictionary of column_name: value pairs.
|
||||
"""
|
||||
data = load_data_file(data_file)
|
||||
|
||||
headers = data.headers
|
||||
|
||||
rows = []
|
||||
|
||||
for row in data:
|
||||
rows.append(dict(zip(headers, row)))
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def get_field_label(field) -> str:
|
||||
"""Return the label for a field in a serializer class.
|
||||
|
||||
|
||||
@@ -75,6 +75,24 @@ class GeneralExtraLineList(DataExportViewMixin):
|
||||
filterset_fields = ['order']
|
||||
|
||||
|
||||
class OrderCreateMixin:
|
||||
"""Mixin class which handles order creation via API."""
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""Save user information on order creation."""
|
||||
serializer = self.get_serializer(data=self.clean_data(request.data))
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
item = serializer.save()
|
||||
item.created_by = request.user
|
||||
item.save()
|
||||
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(
|
||||
serializer.data, status=status.HTTP_201_CREATED, headers=headers
|
||||
)
|
||||
|
||||
|
||||
class OrderFilter(rest_filters.FilterSet):
|
||||
"""Base class for custom API filters for the OrderList endpoint."""
|
||||
|
||||
@@ -260,7 +278,9 @@ class PurchaseOrderMixin:
|
||||
return queryset
|
||||
|
||||
|
||||
class PurchaseOrderList(PurchaseOrderMixin, DataExportViewMixin, ListCreateAPI):
|
||||
class PurchaseOrderList(
|
||||
PurchaseOrderMixin, OrderCreateMixin, DataExportViewMixin, ListCreateAPI
|
||||
):
|
||||
"""API endpoint for accessing a list of PurchaseOrder objects.
|
||||
|
||||
- GET: Return list of PurchaseOrder objects (with filters)
|
||||
@@ -722,7 +742,9 @@ class SalesOrderMixin:
|
||||
return queryset
|
||||
|
||||
|
||||
class SalesOrderList(SalesOrderMixin, DataExportViewMixin, ListCreateAPI):
|
||||
class SalesOrderList(
|
||||
SalesOrderMixin, OrderCreateMixin, DataExportViewMixin, ListCreateAPI
|
||||
):
|
||||
"""API endpoint for accessing a list of SalesOrder objects.
|
||||
|
||||
- GET: Return list of SalesOrder objects (with filters)
|
||||
@@ -731,20 +753,6 @@ class SalesOrderList(SalesOrderMixin, DataExportViewMixin, ListCreateAPI):
|
||||
|
||||
filterset_class = SalesOrderFilter
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""Save user information on create."""
|
||||
serializer = self.get_serializer(data=self.clean_data(request.data))
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
item = serializer.save()
|
||||
item.created_by = request.user
|
||||
item.save()
|
||||
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(
|
||||
serializer.data, status=status.HTTP_201_CREATED, headers=headers
|
||||
)
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""Perform custom filtering operations on the SalesOrder queryset."""
|
||||
queryset = super().filter_queryset(queryset)
|
||||
@@ -1339,25 +1347,13 @@ class ReturnOrderMixin:
|
||||
return queryset
|
||||
|
||||
|
||||
class ReturnOrderList(ReturnOrderMixin, DataExportViewMixin, ListCreateAPI):
|
||||
class ReturnOrderList(
|
||||
ReturnOrderMixin, OrderCreateMixin, DataExportViewMixin, ListCreateAPI
|
||||
):
|
||||
"""API endpoint for accessing a list of ReturnOrder objects."""
|
||||
|
||||
filterset_class = ReturnOrderFilter
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""Save user information on create."""
|
||||
serializer = self.get_serializer(data=self.clean_data(request.data))
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
item = serializer.save()
|
||||
item.created_by = request.user
|
||||
item.save()
|
||||
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(
|
||||
serializer.data, status=status.HTTP_201_CREATED, headers=headers
|
||||
)
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
|
||||
ordering_field_aliases = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Generated by Django 4.2.16 on 2024-11-28 04:31
|
||||
|
||||
from django.db import migrations
|
||||
from django.db import migrations, connection
|
||||
|
||||
|
||||
def update_shipment_date(apps, schema_editor):
|
||||
@@ -18,9 +18,15 @@ def update_shipment_date(apps, schema_editor):
|
||||
shipment_date__isnull=True
|
||||
)
|
||||
|
||||
updated_orders = 0
|
||||
update_count = 0
|
||||
|
||||
cursor = connection.cursor()
|
||||
|
||||
for order in orders:
|
||||
|
||||
# Check that the shipment date is actually null here
|
||||
assert order.shipment_date is None, f"SalesOrder {order.pk} has non-null shipment_date"
|
||||
|
||||
# Find the latest shipment date for any associated allocations
|
||||
shipments = order.shipments.filter(shipment_date__isnull=False)
|
||||
latest_shipment = shipments.order_by('-shipment_date').first()
|
||||
@@ -29,13 +35,21 @@ def update_shipment_date(apps, schema_editor):
|
||||
continue
|
||||
|
||||
# Update the order with the new shipment date
|
||||
order.shipment_date = latest_shipment.shipment_date
|
||||
order.save()
|
||||
shipment_date = latest_shipment.shipment_date
|
||||
|
||||
updated_orders += 1
|
||||
|
||||
if updated_orders > 0:
|
||||
print(f"Updated {updated_orders} SalesOrder objects with missing shipment_date")
|
||||
# Raw SQL to prevent some weird migration "order of operations" issues
|
||||
# Reference: https://github.com/inventree/InvenTree/pull/8814
|
||||
query = f"UPDATE order_salesorder SET shipment_date = '{shipment_date}' WHERE id = {order.pk}"
|
||||
cursor.execute(query)
|
||||
|
||||
# Fetch the updated object, check that the shipment date has been updated
|
||||
order.refresh_from_db()
|
||||
assert order.shipment_date is not None, f"SalesOrder {order.pk} still has missing shipment_date"
|
||||
|
||||
update_count += 1
|
||||
|
||||
if update_count > 0:
|
||||
print(f"Updated {update_count} SalesOrder shipment dates")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -255,7 +255,10 @@ class PurchaseOrderTest(OrderTest):
|
||||
|
||||
order = models.PurchaseOrder.objects.get(pk=response.data['pk'])
|
||||
|
||||
self.assertEqual(order.reference, 'PO-92233720368547758089999999999999999')
|
||||
# Check that the created_by field is set correctly
|
||||
self.assertEqual(order.created_by.username, 'testuser')
|
||||
|
||||
self.assertEqual(order.reference, huge_number)
|
||||
self.assertEqual(order.reference_int, 0x7FFFFFFF)
|
||||
|
||||
def test_po_reference_wildcard_default(self):
|
||||
@@ -1407,6 +1410,11 @@ class SalesOrderTest(OrderTest):
|
||||
# Grab the PK for the newly created SalesOrder
|
||||
pk = response.data['pk']
|
||||
|
||||
# Basic checks against the newly created SalesOrder
|
||||
so = models.SalesOrder.objects.get(pk=pk)
|
||||
self.assertEqual(so.reference, 'SO-12345')
|
||||
self.assertEqual(so.created_by.username, 'testuser')
|
||||
|
||||
# Try to create a SO with identical reference (should fail)
|
||||
response = self.post(
|
||||
url,
|
||||
|
||||
@@ -198,3 +198,52 @@ class TestAdditionalLineMigration(MigratorTestCase):
|
||||
# so = SalesOrder.objects.get(reference=f"{ii}-xyz")
|
||||
# self.assertEqual(so.extra_lines, 1)
|
||||
# self.assertEqual(so.lines.count(), 1)
|
||||
|
||||
|
||||
class TestShipmentDateMigration(MigratorTestCase):
|
||||
"""Test data migration which fixes empty 'shipment date' on SalesOrder model.
|
||||
|
||||
Ref: 0105_auto_20241128_0431.py
|
||||
"""
|
||||
|
||||
migrate_from = ('order', '0100_remove_returnorderattachment_order_and_more')
|
||||
migrate_to = ('order', '0105_auto_20241128_0431')
|
||||
|
||||
def prepare(self):
|
||||
"""Create initial SalesOrder dataset."""
|
||||
Company = self.old_state.apps.get_model('company', 'company')
|
||||
SalesOrder = self.old_state.apps.get_model('order', 'salesorder')
|
||||
SalesOrderShipment = self.old_state.apps.get_model(
|
||||
'order', 'salesordershipment'
|
||||
)
|
||||
|
||||
# Create a customer
|
||||
customer = Company.objects.create(
|
||||
name='Customer A',
|
||||
description='A great customer!',
|
||||
is_customer=True,
|
||||
is_supplier=False,
|
||||
)
|
||||
|
||||
# Create a SalesOrder (Completed, but missing shipment date)
|
||||
order = SalesOrder.objects.create(
|
||||
customer=customer,
|
||||
reference='SO-999',
|
||||
description='A test sales order',
|
||||
shipment_date=None,
|
||||
status=SalesOrderStatus.COMPLETE,
|
||||
)
|
||||
|
||||
# Add a shipment
|
||||
SalesOrderShipment.objects.create(order=order, shipment_date='2024-11-28')
|
||||
|
||||
self.assertEqual(order.shipments.count(), 1)
|
||||
self.assertIsNone(order.shipment_date)
|
||||
|
||||
def test_migration(self):
|
||||
"""Test that the migration has correctly updated the SalesOrder objects."""
|
||||
SalesOrder = self.new_state.apps.get_model('order', 'salesorder')
|
||||
|
||||
order = SalesOrder.objects.get(reference='SO-999')
|
||||
self.assertIsNotNone(order.shipment_date)
|
||||
self.assertEqual(order.shipment_date.isoformat(), '2024-11-28')
|
||||
|
||||
@@ -92,7 +92,7 @@ class BarcodeView(CreateAPIView):
|
||||
|
||||
if num_scans > max_scans:
|
||||
n = num_scans - max_scans
|
||||
old_scan_ids = (
|
||||
old_scan_ids = list(
|
||||
BarcodeScanResult.objects.all()
|
||||
.order_by('timestamp')
|
||||
.values_list('pk', flat=True)[:n]
|
||||
@@ -654,7 +654,7 @@ class BarcodeSOAllocate(BarcodeView):
|
||||
return shipment
|
||||
|
||||
shipments = order.models.SalesOrderShipment.objects.filter(
|
||||
order=sales_order, delivery_date=None
|
||||
order=sales_order, shipment_date=None
|
||||
)
|
||||
|
||||
if shipments.count() == 1:
|
||||
|
||||
@@ -142,7 +142,7 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model):
|
||||
|
||||
def save(self, force_insert=False, force_update=False, *args, **kwargs):
|
||||
"""Extend save method to reload plugins if the 'active' status changes."""
|
||||
reload = kwargs.pop('no_reload', False) # check if no_reload flag is set
|
||||
no_reload = kwargs.pop('no_reload', False) # check if no_reload flag is set
|
||||
|
||||
super().save(force_insert, force_update, *args, **kwargs)
|
||||
|
||||
@@ -150,10 +150,10 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model):
|
||||
# Force active if builtin
|
||||
self.active = True
|
||||
|
||||
if not reload and self.active != self.__org_active:
|
||||
if not no_reload and self.active != self.__org_active:
|
||||
if settings.PLUGIN_TESTING:
|
||||
warnings.warn('A reload was triggered', stacklevel=2)
|
||||
registry.reload_plugins()
|
||||
warnings.warn('A plugin registry reload was triggered', stacklevel=2)
|
||||
registry.reload_plugins(full_reload=True, force_reload=True, collect=True)
|
||||
|
||||
@admin.display(boolean=True, description=_('Installed'))
|
||||
def is_installed(self) -> bool:
|
||||
|
||||
@@ -290,7 +290,7 @@ class PluginsRegistry:
|
||||
collect,
|
||||
)
|
||||
|
||||
if collect:
|
||||
if collect and not settings.PLUGINS_INSTALL_DISABLED:
|
||||
logger.info('Collecting plugins')
|
||||
self.install_plugin_file()
|
||||
self.plugin_modules = self.collect_plugins()
|
||||
@@ -452,6 +452,8 @@ class PluginsRegistry:
|
||||
|
||||
Args:
|
||||
plugin: Plugin module
|
||||
configs: Plugin configuration dictionary
|
||||
force_reload (bool, optional): Force reload of plugin. Defaults to False.
|
||||
"""
|
||||
from InvenTree import version
|
||||
|
||||
@@ -484,6 +486,7 @@ class PluginsRegistry:
|
||||
|
||||
# Check if this is a 'builtin' plugin
|
||||
builtin = plugin.check_is_builtin()
|
||||
sample = plugin.check_is_sample()
|
||||
|
||||
package_name = None
|
||||
|
||||
@@ -509,11 +512,37 @@ class PluginsRegistry:
|
||||
# Initialize package - we can be sure that an admin has activated the plugin
|
||||
logger.debug('Loading plugin `%s`', plg_name)
|
||||
|
||||
# If this is a third-party plugin, reload the source module
|
||||
# This is required to ensure that separate processes are using the same code
|
||||
if not builtin and not sample:
|
||||
plugin_name = plugin.__name__
|
||||
module_name = plugin.__module__
|
||||
|
||||
if plugin_module := sys.modules.get(module_name):
|
||||
logger.debug('Reloading plugin `%s`', plg_name)
|
||||
# Reload the module
|
||||
try:
|
||||
importlib.reload(plugin_module)
|
||||
plugin = getattr(plugin_module, plugin_name)
|
||||
except ModuleNotFoundError:
|
||||
# No module found - try to import it directly
|
||||
try:
|
||||
raw_module = _load_source(
|
||||
module_name, plugin_module.__file__
|
||||
)
|
||||
plugin = getattr(raw_module, plugin_name)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
logger.exception('Failed to reload plugin `%s`', plg_name)
|
||||
|
||||
try:
|
||||
t_start = time.time()
|
||||
plg_i: InvenTreePlugin = plugin()
|
||||
dt = time.time() - t_start
|
||||
logger.debug('Loaded plugin `%s` in %.3fs', plg_name, dt)
|
||||
except ModuleNotFoundError as e:
|
||||
raise e
|
||||
except Exception as error:
|
||||
handle_error(
|
||||
error, log_name='init'
|
||||
@@ -579,9 +608,7 @@ class PluginsRegistry:
|
||||
except Exception as error:
|
||||
# Handle the error, log it and try again
|
||||
if attempts == 0:
|
||||
handle_error(
|
||||
error, log_name='init', do_raise=settings.PLUGIN_TESTING
|
||||
)
|
||||
handle_error(error, log_name='init', do_raise=True)
|
||||
|
||||
logger.exception(
|
||||
'[PLUGIN] Encountered an error with %s:\n%s',
|
||||
@@ -746,7 +773,7 @@ class PluginsRegistry:
|
||||
|
||||
if old_hash != self.registry_hash:
|
||||
try:
|
||||
logger.debug(
|
||||
logger.info(
|
||||
'Updating plugin registry hash: %s', str(self.registry_hash)
|
||||
)
|
||||
set_global_setting(
|
||||
@@ -840,11 +867,16 @@ def _load_source(modname, filename):
|
||||
|
||||
See https://docs.python.org/3/whatsnew/3.12.html#imp
|
||||
"""
|
||||
loader = importlib.machinery.SourceFileLoader(modname, filename)
|
||||
spec = importlib.util.spec_from_file_location(modname, filename, loader=loader)
|
||||
if modname in sys.modules:
|
||||
del sys.modules[modname]
|
||||
|
||||
# loader = importlib.machinery.SourceFileLoader(modname, filename)
|
||||
spec = importlib.util.spec_from_file_location(modname, filename) # , loader=loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
# The module is always executed and not cached in sys.modules.
|
||||
# Uncomment the following line to cache the module.
|
||||
# sys.modules[module.__name__] = module
|
||||
loader.exec_module(module)
|
||||
|
||||
sys.modules[module.__name__] = module
|
||||
|
||||
if spec.loader:
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
return module
|
||||
|
||||
@@ -205,7 +205,7 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
|
||||
|
||||
plg_inactive.active = True
|
||||
plg_inactive.save()
|
||||
self.assertEqual(cm.warning.args[0], 'A reload was triggered')
|
||||
self.assertEqual(cm.warning.args[0], 'A plugin registry reload was triggered')
|
||||
|
||||
def test_check_plugin(self):
|
||||
"""Test check_plugin function."""
|
||||
|
||||
@@ -4,9 +4,11 @@ import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import textwrap
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
@@ -18,6 +20,9 @@ from plugin.samples.integration.another_sample import (
|
||||
)
|
||||
from plugin.samples.integration.sample import SampleIntegrationPlugin
|
||||
|
||||
# Directory for testing plugins during CI
|
||||
PLUGIN_TEST_DIR = '_testfolder/test_plugins'
|
||||
|
||||
|
||||
class PluginTagTests(TestCase):
|
||||
"""Tests for the plugin extras."""
|
||||
@@ -287,3 +292,111 @@ class RegistryTests(TestCase):
|
||||
self.assertEqual(
|
||||
registry.errors.get('init')[0]['broken_sample'], "'This is a dummy error'"
|
||||
)
|
||||
|
||||
@override_settings(PLUGIN_TESTING=True, PLUGIN_TESTING_SETUP=True)
|
||||
@patch.dict(os.environ, {'INVENTREE_PLUGIN_TEST_DIR': PLUGIN_TEST_DIR})
|
||||
def test_registry_reload(self):
|
||||
"""Test that the registry correctly reloads plugin modules.
|
||||
|
||||
- Create a simple plugin which we can change the version
|
||||
- Ensure that the "hash" of the plugin registry changes
|
||||
"""
|
||||
dummy_file = os.path.join(PLUGIN_TEST_DIR, 'dummy_ci_plugin.py')
|
||||
|
||||
# Ensure the plugin dir exists
|
||||
os.makedirs(PLUGIN_TEST_DIR, exist_ok=True)
|
||||
|
||||
# Create an __init__.py file
|
||||
init_file = os.path.join(PLUGIN_TEST_DIR, '__init__.py')
|
||||
if not os.path.exists(init_file):
|
||||
with open(os.path.join(init_file), 'w', encoding='utf-8') as f:
|
||||
f.write('')
|
||||
|
||||
def plugin_content(version):
|
||||
"""Return the content of the plugin file."""
|
||||
content = f"""
|
||||
from plugin import InvenTreePlugin
|
||||
|
||||
PLG_VERSION = "{version}"
|
||||
|
||||
print(">>> LOADING DUMMY PLUGIN v" + PLG_VERSION + " <<<")
|
||||
|
||||
class DummyCIPlugin(InvenTreePlugin):
|
||||
|
||||
NAME = "DummyCIPlugin"
|
||||
SLUG = "dummyci"
|
||||
TITLE = "Dummy plugin for CI testing"
|
||||
|
||||
VERSION = PLG_VERSION
|
||||
|
||||
"""
|
||||
|
||||
return textwrap.dedent(content)
|
||||
|
||||
def create_plugin_file(
|
||||
version: str, enabled: bool = True, reload: bool = True
|
||||
) -> str:
|
||||
"""Create a plugin file with the given version.
|
||||
|
||||
Arguments:
|
||||
version: The version string to use for the plugin file
|
||||
enabled: Whether the plugin should be enabled or not
|
||||
|
||||
Returns:
|
||||
str: The plugin registry hash
|
||||
"""
|
||||
import time
|
||||
|
||||
content = plugin_content(version)
|
||||
|
||||
with open(dummy_file, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
# Wait for the file to be written
|
||||
time.sleep(2)
|
||||
|
||||
if reload:
|
||||
# Ensure the plugin is activated
|
||||
registry.set_plugin_state('dummyci', enabled)
|
||||
registry.reload_plugins(
|
||||
full_reload=True, collect=True, force_reload=True
|
||||
)
|
||||
|
||||
registry.update_plugin_hash()
|
||||
|
||||
return registry.registry_hash
|
||||
|
||||
# Initial hash, with plugin disabled
|
||||
hash_disabled = create_plugin_file('0.0.1', enabled=False, reload=False)
|
||||
|
||||
# Perform initial registry reload
|
||||
registry.reload_plugins(full_reload=True, collect=True, force_reload=True)
|
||||
|
||||
# Start plugin in known state
|
||||
registry.set_plugin_state('dummyci', False)
|
||||
|
||||
hash_disabled = create_plugin_file('0.0.1', enabled=False)
|
||||
|
||||
# Enable the plugin
|
||||
hash_enabled = create_plugin_file('0.1.0', enabled=True)
|
||||
|
||||
# Hash must be different!
|
||||
self.assertNotEqual(hash_disabled, hash_enabled)
|
||||
|
||||
plugin_hash = hash_enabled
|
||||
|
||||
for v in ['0.1.1', '7.1.2', '1.2.1', '4.0.1']:
|
||||
h = create_plugin_file(v, enabled=True)
|
||||
self.assertNotEqual(plugin_hash, h)
|
||||
plugin_hash = h
|
||||
|
||||
# Revert back to original 'version'
|
||||
h = create_plugin_file('0.1.0', enabled=True)
|
||||
self.assertEqual(hash_enabled, h)
|
||||
|
||||
# Disable the plugin
|
||||
h = create_plugin_file('0.0.1', enabled=False)
|
||||
self.assertEqual(hash_disabled, h)
|
||||
|
||||
# Finally, ensure that the plugin file is removed after testing
|
||||
os.remove(dummy_file)
|
||||
|
||||
@@ -7,6 +7,7 @@ from pathlib import Path
|
||||
from django.apps import AppConfig
|
||||
from django.core.exceptions import AppRegistryNotReady
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db.utils import IntegrityError, OperationalError, ProgrammingError
|
||||
|
||||
from maintenance_mode.core import maintenance_mode_on, set_maintenance_mode
|
||||
@@ -55,6 +56,18 @@ class ReportConfig(AppConfig):
|
||||
|
||||
set_maintenance_mode(False)
|
||||
|
||||
def file_from_template(self, dir_name: str, file_name: str) -> ContentFile:
|
||||
"""Construct a new ContentFile from a template file."""
|
||||
logger.info('Creating %s template file: %s', dir_name, file_name)
|
||||
|
||||
return ContentFile(
|
||||
Path(__file__)
|
||||
.parent.joinpath('templates', dir_name, file_name)
|
||||
.open('r')
|
||||
.read(),
|
||||
os.path.basename(file_name),
|
||||
)
|
||||
|
||||
def create_default_labels(self):
|
||||
"""Create default label templates."""
|
||||
# Test if models are ready
|
||||
@@ -106,29 +119,25 @@ class ReportConfig(AppConfig):
|
||||
]
|
||||
|
||||
for template in label_templates:
|
||||
# Ignore matching templates which are already in the database
|
||||
if report.models.LabelTemplate.objects.filter(
|
||||
name=template['name']
|
||||
).exists():
|
||||
continue
|
||||
|
||||
filename = template.pop('file')
|
||||
|
||||
template_file = Path(__file__).parent.joinpath(
|
||||
'templates', 'label', filename
|
||||
)
|
||||
|
||||
if not template_file.exists():
|
||||
logger.warning("Missing template file: '%s'", template['name'])
|
||||
# Template already exists in the database - check that the file exists too
|
||||
if existing_template := report.models.LabelTemplate.objects.filter(
|
||||
name=template['name'], model_type=template['model_type']
|
||||
).first():
|
||||
if not default_storage.exists(existing_template.template.name):
|
||||
# The file does not exist in the storage system - add it in
|
||||
existing_template.template = self.file_from_template(
|
||||
'label', filename
|
||||
)
|
||||
existing_template.save()
|
||||
continue
|
||||
|
||||
# Read the existing template file
|
||||
data = template_file.open('r').read()
|
||||
|
||||
# Otherwise, create a new entry
|
||||
try:
|
||||
# Create a new entry
|
||||
report.models.LabelTemplate.objects.create(
|
||||
**template, template=ContentFile(data, os.path.basename(filename))
|
||||
**template, template=self.file_from_template('label', filename)
|
||||
)
|
||||
logger.info("Creating new label template: '%s'", template['name'])
|
||||
except Exception:
|
||||
@@ -202,29 +211,24 @@ class ReportConfig(AppConfig):
|
||||
]
|
||||
|
||||
for template in report_templates:
|
||||
# Ignore matching templates which are already in the database
|
||||
if report.models.ReportTemplate.objects.filter(
|
||||
name=template['name']
|
||||
).exists():
|
||||
continue
|
||||
|
||||
filename = template.pop('file')
|
||||
|
||||
template_file = Path(__file__).parent.joinpath(
|
||||
'templates', 'report', filename
|
||||
)
|
||||
|
||||
if not template_file.exists():
|
||||
logger.warning("Missing template file: '%s'", template['name'])
|
||||
# Template already exists in the database - check that the file exists too
|
||||
if existing_template := report.models.ReportTemplate.objects.filter(
|
||||
name=template['name'], model_type=template['model_type']
|
||||
).first():
|
||||
if not default_storage.exists(existing_template.template.name):
|
||||
# The file does not exist in the storage system - add it in
|
||||
existing_template.template = self.file_from_template(
|
||||
'report', filename
|
||||
)
|
||||
existing_template.save()
|
||||
continue
|
||||
|
||||
# Read the existing template file
|
||||
data = template_file.open('r').read()
|
||||
|
||||
# Create a new entry
|
||||
# Otherwise, create a new entry
|
||||
try:
|
||||
report.models.ReportTemplate.objects.create(
|
||||
**template, template=ContentFile(data, os.path.basename(filename))
|
||||
**template, template=self.file_from_template('report', filename)
|
||||
)
|
||||
logger.info("Created new report template: '%s'", template['name'])
|
||||
except Exception:
|
||||
|
||||
@@ -429,7 +429,7 @@ class StockItem(
|
||||
'parameters': self.part.parameters_map(),
|
||||
'quantity': InvenTree.helpers.normalize(self.quantity),
|
||||
'result_list': self.testResultList(include_installed=True),
|
||||
'results': self.testResultMap(include_installed=True),
|
||||
'results': self.testResultMap(include_installed=True, cascade=True),
|
||||
'serial': self.serial,
|
||||
'stock_item': self,
|
||||
'tests': self.testResultMap(),
|
||||
@@ -1508,17 +1508,22 @@ class StockItem(
|
||||
"""
|
||||
return self.children.count()
|
||||
|
||||
def is_in_stock(self, check_status: bool = True):
|
||||
def is_in_stock(
|
||||
self, check_status: bool = True, check_quantity: bool = True
|
||||
) -> bool:
|
||||
"""Return True if this StockItem is "in stock".
|
||||
|
||||
Args:
|
||||
check_status: If True, check the status of the StockItem. Defaults to True.
|
||||
check_quantity: If True, check the quantity of the StockItem. Defaults to True.
|
||||
"""
|
||||
if check_status and self.status not in StockStatusGroups.AVAILABLE_CODES:
|
||||
return False
|
||||
|
||||
if check_quantity and self.quantity <= 0:
|
||||
return False
|
||||
|
||||
return all([
|
||||
self.quantity > 0, # Quantity must be greater than zero
|
||||
self.sales_order is None, # Not assigned to a SalesOrder
|
||||
self.belongs_to is None, # Not installed inside another StockItem
|
||||
self.customer is None, # Not assigned to a customer
|
||||
@@ -1979,9 +1984,18 @@ class StockItem(
|
||||
Returns:
|
||||
The new StockItem object
|
||||
|
||||
Raises:
|
||||
ValidationError: If the stock item cannot be split
|
||||
|
||||
- The provided quantity will be subtracted from this item and given to the new one.
|
||||
- The new item will have a different StockItem ID, while this will remain the same.
|
||||
"""
|
||||
# Run initial checks to test if the stock item can actually be "split"
|
||||
|
||||
# Cannot split a stock item which is in production
|
||||
if self.is_building:
|
||||
raise ValidationError(_('Stock item is currently in production'))
|
||||
|
||||
notes = kwargs.get('notes', '')
|
||||
|
||||
# Do not split a serialized part
|
||||
@@ -2401,6 +2415,7 @@ class StockItem(
|
||||
"""
|
||||
# Do we wish to include test results from installed items?
|
||||
include_installed = kwargs.pop('include_installed', False)
|
||||
cascade = kwargs.pop('cascade', False)
|
||||
|
||||
# Filter results by "date", so that newer results
|
||||
# will override older ones.
|
||||
@@ -2411,9 +2426,6 @@ class StockItem(
|
||||
for result in results:
|
||||
result_map[result.key] = result
|
||||
|
||||
# Do we wish to "cascade" and include test results from installed stock items?
|
||||
cascade = kwargs.get('cascade', False)
|
||||
|
||||
if include_installed:
|
||||
installed_items = self.get_installed_items(cascade=cascade)
|
||||
|
||||
|
||||
@@ -344,7 +344,7 @@ class StockItemSerializer(
|
||||
|
||||
export_only_fields = ['part_pricing_min', 'part_pricing_max']
|
||||
|
||||
import_exclude_fields = ['use_pack_size', 'tags']
|
||||
import_exclude_fields = ['use_pack_size']
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
@@ -1142,7 +1142,7 @@ class LocationSerializer(
|
||||
):
|
||||
"""Detailed information about a stock location."""
|
||||
|
||||
import_exclude_fields = ['tags']
|
||||
import_exclude_fields = []
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
@@ -1565,16 +1565,18 @@ class StockAdjustmentItemSerializer(serializers.Serializer):
|
||||
help_text=_('StockItem primary key value'),
|
||||
)
|
||||
|
||||
def validate_pk(self, pk):
|
||||
def validate_pk(self, stock_item: StockItem) -> StockItem:
|
||||
"""Ensure the stock item is valid."""
|
||||
allow_out_of_stock_transfer = get_global_setting(
|
||||
'STOCK_ALLOW_OUT_OF_STOCK_TRANSFER', backup_value=False, cache=False
|
||||
)
|
||||
|
||||
if not allow_out_of_stock_transfer and not pk.is_in_stock(check_status=False):
|
||||
if not allow_out_of_stock_transfer and not stock_item.is_in_stock(
|
||||
check_status=False, check_quantity=False
|
||||
):
|
||||
raise ValidationError(_('Stock item is not in stock'))
|
||||
|
||||
return pk
|
||||
return stock_item
|
||||
|
||||
quantity = serializers.DecimalField(
|
||||
max_digits=15, decimal_places=5, min_value=Decimal(0), required=True
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{% extends "account/base.html" %}
|
||||
{% extends "skeleton.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
@@ -5,11 +5,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
||||
<!-- Required meta tags -->
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="{% static 'img/favicon/apple-icon-57x57.png' %}">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="{% static 'img/favicon/apple-icon-60x60.png' %}">
|
||||
@@ -28,8 +26,6 @@
|
||||
<meta name="msapplication-TileColor" content="#ffffff">
|
||||
<meta name="msapplication-TileImage" content="{% static 'img/favicon/ms-icon-144x144.png' %}">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
|
||||
|
||||
<!-- CSS -->
|
||||
<link rel="stylesheet" href="{% static 'fontawesome/css/brands.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'fontawesome/css/solid.css' %}">
|
||||
@@ -37,13 +33,10 @@
|
||||
<link rel="stylesheet" href="{% static 'select2/css/select2.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'select2/css/select2-bootstrap-5-theme.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/inventree.css' %}">
|
||||
|
||||
<link rel="stylesheet" href="{% get_color_theme_css request.user %}">
|
||||
|
||||
<title>
|
||||
{% inventree_title %} | {% block head_title %}{% endblock head_title %}
|
||||
</title>
|
||||
|
||||
{% block extra_head %}
|
||||
{% endblock extra_head %}
|
||||
</head>
|
||||
@@ -85,37 +78,26 @@
|
||||
|
||||
<!-- general JS -->
|
||||
{% include "third_party_js.html" %}
|
||||
|
||||
<script type='text/javascript' src='{% static "script/inventree/inventree.js" %}'></script>
|
||||
<script type='text/javascript' src='{% static "script/inventree/message.js" %}'></script>
|
||||
|
||||
<script type='text/javascript'>
|
||||
|
||||
$(document).ready(function () {
|
||||
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
showMessage("{{ message }}");
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
showCachedAlerts();
|
||||
|
||||
// Add brand icons for SSO providers, if available
|
||||
$('.socialaccount_provider').each(function(i, obj) {
|
||||
var el = $(this);
|
||||
var tag = el.attr('brand_name');
|
||||
|
||||
var icon = window.FontAwesome.icon({prefix: 'fab', iconName: tag});
|
||||
|
||||
if (icon) {
|
||||
el.prepend(`<span class='fab fa-${tag}'></span> `);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% load spa_helper %}
|
||||
{% load inventree_extras %}
|
||||
{% spa_bundle as bundle %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -12,8 +13,17 @@
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
{% spa_settings %}
|
||||
{% spa_bundle %}
|
||||
<div id="spa_settings">{% spa_settings %}</div>
|
||||
{% if bundle == "NOT_FOUND" %}
|
||||
<div id="spa_bundle_error">
|
||||
<div>
|
||||
<h1>INVE-E1 - No frontend included</h1>
|
||||
<p>The frontend bundle could not be found. Please check that your deployment method includes the bundle or check the <a href="https://docs.inventree.org/en/stable/faq/">FAQ</a>.<br/>
|
||||
<span>Install method: <code>{% inventree_installer %}</code></span></p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div id="spa_bundle">{{ bundle }}</div>
|
||||
{% endif %}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -37,13 +37,13 @@ def spa_bundle(manifest_path: Union[str, Path] = '', app: str = 'web'):
|
||||
# Final check - fail if manifest file not found
|
||||
if not manifest.exists():
|
||||
logger.error('Manifest file not found')
|
||||
return
|
||||
return 'NOT_FOUND'
|
||||
|
||||
try:
|
||||
manifest_data = json.load(manifest.open())
|
||||
except (TypeError, json.decoder.JSONDecodeError):
|
||||
logger.exception('Failed to parse manifest file')
|
||||
return
|
||||
return ''
|
||||
|
||||
return_string = ''
|
||||
# JS (based on index.html file as entrypoint)
|
||||
|
||||
@@ -26,9 +26,8 @@ class TemplateTagTest(InvenTreeTestCase):
|
||||
def test_spa_bundle(self):
|
||||
"""Test the 'spa_bundle' template tag."""
|
||||
resp = spa_helper.spa_bundle()
|
||||
if not resp:
|
||||
if resp == 'NOT_FOUND':
|
||||
# No Vite, no test
|
||||
# TODO: Add a test for the non-Vite case (docker)
|
||||
return # pragma: no cover
|
||||
|
||||
shipped_js = resp.split('<script type="module" src="')[1:]
|
||||
@@ -41,7 +40,7 @@ class TemplateTagTest(InvenTreeTestCase):
|
||||
manifest_file.with_suffix('.json.bak')
|
||||
) # Rename
|
||||
resp = spa_helper.spa_bundle()
|
||||
self.assertIsNone(resp)
|
||||
self.assertEqual(resp, 'NOT_FOUND')
|
||||
|
||||
# Try with differing name
|
||||
resp = spa_helper.spa_bundle(new_name)
|
||||
@@ -50,7 +49,7 @@ class TemplateTagTest(InvenTreeTestCase):
|
||||
# Broken manifest file
|
||||
manifest_file.write_text('broken')
|
||||
resp = spa_helper.spa_bundle(manifest_file)
|
||||
self.assertIsNone(resp)
|
||||
self.assertEqual(resp, '')
|
||||
|
||||
new_name.rename(manifest_file.with_suffix('.json')) # Name back
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ django-weasyprint # django weasyprint integration
|
||||
djangorestframework<3.15 # DRF framework # FIXED 2024-06-26 see https://github.com/inventree/InvenTree/pull/7521
|
||||
djangorestframework-simplejwt[crypto] # JWT authentication
|
||||
django-xforwardedfor-middleware # IP forwarding metadata
|
||||
dj-rest-auth # Authentication API endpoints
|
||||
dj-rest-auth==7.0.0 # Authentication API endpoints # FIXED 2024-12-22 due to https://github.com/inventree/InvenTree/issues/8707
|
||||
dulwich # pure Python git integration
|
||||
drf-spectacular # DRF API documentation
|
||||
feedparser # RSS newsfeed parser
|
||||
|
||||
@@ -15,7 +15,7 @@ export function setApiDefaults() {
|
||||
const token = useUserState.getState().token;
|
||||
|
||||
api.defaults.baseURL = host;
|
||||
api.defaults.timeout = 2500;
|
||||
api.defaults.timeout = 5000;
|
||||
|
||||
api.defaults.withCredentials = true;
|
||||
api.defaults.withXSRFToken = true;
|
||||
|
||||
@@ -19,10 +19,12 @@ import { useHover } from '@mantine/hooks';
|
||||
import { modals } from '@mantine/modals';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { api } from '../../App';
|
||||
import type { UserRoles } from '../../enums/Roles';
|
||||
import { cancelEvent } from '../../functions/events';
|
||||
import { InvenTreeIcon } from '../../functions/icons';
|
||||
import { showApiErrorMessage } from '../../functions/notifications';
|
||||
import { useEditApiFormModal } from '../../hooks/UseForm';
|
||||
import { useGlobalSettingsState } from '../../states/SettingsState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
@@ -159,12 +161,24 @@ function UploadModal({
|
||||
const formData = new FormData();
|
||||
formData.append('image', file, file.name);
|
||||
|
||||
const response = await api.patch(apiPath, formData);
|
||||
|
||||
if (response.data.image.includes(file.name)) {
|
||||
setImage(response.data.image);
|
||||
modals.closeAll();
|
||||
}
|
||||
api
|
||||
.patch(apiPath, formData)
|
||||
.then((response) => {
|
||||
setImage(response.data.image);
|
||||
modals.closeAll();
|
||||
showNotification({
|
||||
title: t`Image uploaded`,
|
||||
message: t`Image has been uploaded successfully`,
|
||||
color: 'green'
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
showApiErrorMessage({
|
||||
error: error,
|
||||
title: t`Upload Error`,
|
||||
field: 'image'
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
|
||||
@@ -94,7 +94,7 @@ export interface ApiFormProps {
|
||||
postFormContent?: JSX.Element;
|
||||
successMessage?: string;
|
||||
onFormSuccess?: (data: any) => void;
|
||||
onFormError?: () => void;
|
||||
onFormError?: (response: any) => void;
|
||||
processFormData?: (data: any) => any;
|
||||
table?: TableState;
|
||||
modelType?: ModelType;
|
||||
@@ -431,11 +431,18 @@ export function ApiForm({
|
||||
}
|
||||
});
|
||||
|
||||
/* Set the timeout for the request:
|
||||
* - If a timeout is provided in the props, use that
|
||||
* - If the form contains files, use a longer timeout
|
||||
* - Otherwise, use the default timeout
|
||||
*/
|
||||
const timeout = props.timeout ?? (hasFiles ? 30000 : undefined);
|
||||
|
||||
return api({
|
||||
method: method,
|
||||
url: url,
|
||||
data: hasFiles ? formData : jsonData,
|
||||
timeout: props.timeout,
|
||||
timeout: timeout,
|
||||
headers: {
|
||||
'Content-Type': hasFiles ? 'multipart/form-data' : 'application/json'
|
||||
}
|
||||
@@ -482,7 +489,7 @@ export function ApiForm({
|
||||
default:
|
||||
// Unexpected state on form success
|
||||
invalidResponse(response.status);
|
||||
props.onFormError?.();
|
||||
props.onFormError?.(response);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -534,26 +541,30 @@ export function ApiForm({
|
||||
|
||||
processErrors(error.response.data);
|
||||
setNonFieldErrors(_nonFieldErrors);
|
||||
props.onFormError?.(error);
|
||||
|
||||
break;
|
||||
default:
|
||||
// Unexpected state on form error
|
||||
invalidResponse(error.response.status);
|
||||
props.onFormError?.();
|
||||
props.onFormError?.(error);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
showTimeoutNotification();
|
||||
props.onFormError?.();
|
||||
props.onFormError?.(error);
|
||||
}
|
||||
|
||||
return error;
|
||||
});
|
||||
};
|
||||
|
||||
const onFormError = useCallback<SubmitErrorHandler<FieldValues>>(() => {
|
||||
props.onFormError?.();
|
||||
}, [props.onFormError]);
|
||||
const onFormError = useCallback<SubmitErrorHandler<FieldValues>>(
|
||||
(error: any) => {
|
||||
props.onFormError?.(error);
|
||||
},
|
||||
[props.onFormError]
|
||||
);
|
||||
|
||||
if (optionsLoading || initialDataQuery.isFetching) {
|
||||
return (
|
||||
|
||||
@@ -192,7 +192,7 @@ export function RegistrationForm() {
|
||||
headers: { Authorization: '' }
|
||||
})
|
||||
.then((ret) => {
|
||||
if (ret?.status === 204) {
|
||||
if (ret?.status === 204 || ret?.status === 201) {
|
||||
setIsRegistering(false);
|
||||
showLoginNotification({
|
||||
title: t`Registration successful`,
|
||||
@@ -202,7 +202,7 @@ export function RegistrationForm() {
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.response.status === 400) {
|
||||
if (err.response?.status === 400) {
|
||||
setIsRegistering(false);
|
||||
for (const [key, value] of Object.entries(err.response.data)) {
|
||||
registrationForm.setFieldError(key, value as string);
|
||||
|
||||
@@ -46,6 +46,7 @@ export type ApiFormFieldChoice = {
|
||||
* @param required : Whether the field is required
|
||||
* @param hidden : Whether the field is hidden
|
||||
* @param disabled : Whether the field is disabled
|
||||
* @param error : Optional error message to display
|
||||
* @param exclude : Whether to exclude the field from the submitted data
|
||||
* @param placeholder : The placeholder text to display
|
||||
* @param description : The description to display for the field
|
||||
@@ -88,6 +89,7 @@ export type ApiFormFieldType = {
|
||||
child?: ApiFormFieldType;
|
||||
children?: { [key: string]: ApiFormFieldType };
|
||||
required?: boolean;
|
||||
error?: string;
|
||||
choices?: ApiFormFieldChoice[];
|
||||
hidden?: boolean;
|
||||
disabled?: boolean;
|
||||
@@ -256,7 +258,7 @@ export function ApiFormField({
|
||||
aria-label={`boolean-field-${fieldName}`}
|
||||
radius='lg'
|
||||
size='sm'
|
||||
error={error?.message}
|
||||
error={definition.error ?? error?.message}
|
||||
onChange={(event) => onChange(event.currentTarget.checked)}
|
||||
/>
|
||||
);
|
||||
@@ -277,7 +279,7 @@ export function ApiFormField({
|
||||
id={fieldId}
|
||||
aria-label={`number-field-${field.name}`}
|
||||
value={numericalValue}
|
||||
error={error?.message}
|
||||
error={definition.error ?? error?.message}
|
||||
decimalScale={definition.field_type == 'integer' ? 0 : 10}
|
||||
onChange={(value: number | string | null) => onChange(value)}
|
||||
step={1}
|
||||
@@ -299,7 +301,7 @@ export function ApiFormField({
|
||||
ref={field.ref}
|
||||
radius='sm'
|
||||
value={value}
|
||||
error={error?.message}
|
||||
error={definition.error ?? error?.message}
|
||||
onChange={(payload: File | null) => onChange(payload)}
|
||||
/>
|
||||
);
|
||||
@@ -343,6 +345,7 @@ export function ApiFormField({
|
||||
booleanValue,
|
||||
control,
|
||||
controller,
|
||||
definition,
|
||||
field,
|
||||
fieldId,
|
||||
fieldName,
|
||||
|
||||
@@ -63,7 +63,7 @@ export function ChoiceField({
|
||||
<Select
|
||||
id={fieldId}
|
||||
aria-label={`choice-field-${field.name}`}
|
||||
error={error?.message}
|
||||
error={definition.error ?? error?.message}
|
||||
radius='sm'
|
||||
{...field}
|
||||
onChange={onChange}
|
||||
|
||||
@@ -61,7 +61,7 @@ export default function DateField({
|
||||
radius='sm'
|
||||
ref={field.ref}
|
||||
type={undefined}
|
||||
error={error?.message}
|
||||
error={definition.error ?? error?.message}
|
||||
value={dateValue ?? null}
|
||||
clearable={!definition.required}
|
||||
onChange={onChange}
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function IconField({
|
||||
label={definition.label}
|
||||
description={definition.description}
|
||||
required={definition.required}
|
||||
error={error?.message}
|
||||
error={definition.error ?? error?.message}
|
||||
ref={field.ref}
|
||||
component='button'
|
||||
type='button'
|
||||
|
||||
@@ -284,7 +284,7 @@ export function RelatedModelField({
|
||||
return (
|
||||
<Input.Wrapper
|
||||
{...fieldDefinition}
|
||||
error={error?.message}
|
||||
error={definition.error ?? error?.message}
|
||||
styles={{ description: { paddingBottom: '5px' } }}
|
||||
>
|
||||
<Select
|
||||
|
||||
@@ -258,7 +258,7 @@ export function TableFieldExtraRow({
|
||||
fieldName={fieldName ?? 'field'}
|
||||
fieldDefinition={field}
|
||||
defaultValue={defaultValue}
|
||||
error={error}
|
||||
error={fieldDefinition.error ?? error}
|
||||
/>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function TextField({
|
||||
aria-label={`text-field-${field.name}`}
|
||||
type={definition.field_type}
|
||||
value={rawText || ''}
|
||||
error={error?.message}
|
||||
error={definition.error ?? error?.message}
|
||||
radius='sm'
|
||||
onChange={(event) => onTextChange(event.currentTarget.value)}
|
||||
onBlur={(event) => {
|
||||
@@ -64,7 +64,13 @@ export default function TextField({
|
||||
onChange(event.currentTarget.value);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => onKeyDown(event.code)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.code === 'Enter') {
|
||||
// Bypass debounce on enter key
|
||||
onChange(event.currentTarget.value);
|
||||
}
|
||||
onKeyDown(event.code);
|
||||
}}
|
||||
rightSection={
|
||||
value && !definition.required ? (
|
||||
<IconX size='1rem' color='red' onClick={() => onTextChange('')} />
|
||||
|
||||
@@ -199,7 +199,7 @@ export function RenderInlineModel({
|
||||
{prefix}
|
||||
{image && <Thumbnail src={image} size={18} />}
|
||||
{url ? (
|
||||
<Anchor href={url} onClick={(event: any) => onClick(event)}>
|
||||
<Anchor href='' onClick={(event: any) => onClick(event)}>
|
||||
<Text size='sm'>{primary}</Text>
|
||||
</Anchor>
|
||||
) : (
|
||||
|
||||
@@ -158,7 +158,7 @@ export function SettingList({
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{(keys || allKeys).length === 0 && (
|
||||
{(keys || allKeys)?.length === 0 && (
|
||||
<Text style={{ fontStyle: 'italic' }}>
|
||||
<Trans>No settings specified</Trans>
|
||||
</Text>
|
||||
|
||||
@@ -47,6 +47,7 @@ export const getSupportedLanguages = (): Record<string, string> => {
|
||||
ru: t`Russian`,
|
||||
sk: t`Slovak`,
|
||||
sl: t`Slovenian`,
|
||||
sr: t`Serbian`,
|
||||
sv: t`Swedish`,
|
||||
th: t`Thai`,
|
||||
tr: t`Turkish`,
|
||||
|
||||
@@ -74,3 +74,31 @@ export function showLoginNotification({
|
||||
autoClose: 2500
|
||||
});
|
||||
}
|
||||
|
||||
export function showApiErrorMessage({
|
||||
error,
|
||||
title,
|
||||
message,
|
||||
field
|
||||
}: {
|
||||
error: any;
|
||||
title: string;
|
||||
message?: string;
|
||||
field?: string;
|
||||
}) {
|
||||
// Extract error description from response
|
||||
const error_data: any = error.response?.data ?? {};
|
||||
|
||||
let error_msg: any =
|
||||
message ?? error_data[field ?? 'error'] ?? error_data['non_field_errors'];
|
||||
|
||||
if (!error_msg) {
|
||||
error_msg = t`An error occurred`;
|
||||
}
|
||||
|
||||
notifications.show({
|
||||
title: title,
|
||||
message: error_msg,
|
||||
color: 'red'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -48,9 +48,8 @@ export function useApiFormModal(props: ApiFormModalProps) {
|
||||
modalClose.current();
|
||||
props.onFormSuccess?.(data);
|
||||
},
|
||||
onFormError: () => {
|
||||
modalClose.current();
|
||||
props.onFormError?.();
|
||||
onFormError: (error: any) => {
|
||||
props.onFormError?.(error);
|
||||
}
|
||||
}),
|
||||
[props]
|
||||
|
||||
@@ -167,7 +167,10 @@ export function useTable(tableName: string): TableState {
|
||||
const index = _records.findIndex((r) => r.pk === record.pk);
|
||||
|
||||
if (index >= 0) {
|
||||
_records[index] = record;
|
||||
_records[index] = {
|
||||
..._records[index],
|
||||
...record
|
||||
};
|
||||
} else {
|
||||
_records.push(record);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { Card, Container, Group, Loader, Stack, Text } from '@mantine/core';
|
||||
import { useDebouncedCallback } from '@mantine/hooks';
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
@@ -9,8 +10,10 @@ export default function Logged_In() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const checkLoginStateDebounced = useDebouncedCallback(checkLoginState, 300);
|
||||
|
||||
useEffect(() => {
|
||||
checkLoginState(navigate, location?.state);
|
||||
checkLoginStateDebounced(navigate, location?.state);
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -77,8 +77,8 @@ export default function Set_Password() {
|
||||
})
|
||||
.catch((err) => {
|
||||
if (
|
||||
err.response.status === 400 &&
|
||||
err.response.data?.token == 'Invalid value'
|
||||
err.response?.status === 400 &&
|
||||
err.response?.data?.token == 'Invalid value'
|
||||
) {
|
||||
invalidToken();
|
||||
} else {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ActionButton } from '../../../../components/buttons/ActionButton';
|
||||
import { FactCollection } from '../../../../components/settings/FactCollection';
|
||||
import { GlobalSettingList } from '../../../../components/settings/SettingList';
|
||||
import { ApiEndpoints } from '../../../../enums/ApiEndpoints';
|
||||
import { showApiErrorMessage } from '../../../../functions/notifications';
|
||||
import { useTable } from '../../../../hooks/UseTable';
|
||||
import { apiUrl } from '../../../../states/ApiState';
|
||||
import { InvenTreeTable } from '../../../../tables/InvenTreeTable';
|
||||
@@ -46,10 +47,9 @@ export function CurrencyTable({
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
showNotification({
|
||||
title: t`Exchange rate update error`,
|
||||
message: error,
|
||||
color: 'red'
|
||||
showApiErrorMessage({
|
||||
error: error,
|
||||
title: t`Exchange rate update error`
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
IconUsersGroup
|
||||
} from '@tabler/icons-react';
|
||||
import { type ReactNode, useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import AdminButton from '../../components/buttons/AdminButton';
|
||||
import {
|
||||
@@ -66,6 +66,7 @@ export type CompanyDetailProps = {
|
||||
export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
|
||||
const { id } = useParams();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const user = useUserState();
|
||||
|
||||
const {
|
||||
@@ -283,7 +284,9 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
|
||||
url: ApiEndpoints.company_list,
|
||||
pk: company?.pk,
|
||||
title: t`Delete Company`,
|
||||
onFormSuccess: refreshInstance
|
||||
onFormSuccess: () => {
|
||||
navigate('/');
|
||||
}
|
||||
});
|
||||
|
||||
const companyActions = useMemo(() => {
|
||||
|
||||
@@ -107,18 +107,19 @@ export default function PartStocktakeDetail({
|
||||
return [
|
||||
{
|
||||
accessor: 'quantity',
|
||||
sortable: true,
|
||||
sortable: false,
|
||||
switchable: false
|
||||
},
|
||||
{
|
||||
accessor: 'item_count',
|
||||
title: t`Stock Items`,
|
||||
switchable: true,
|
||||
sortable: true
|
||||
sortable: false
|
||||
},
|
||||
{
|
||||
accessor: 'cost',
|
||||
title: t`Stock Value`,
|
||||
sortable: false,
|
||||
render: (record: any) => {
|
||||
return formatPriceRange(record.cost_min, record.cost_max, {
|
||||
currency: record.cost_min_currency
|
||||
@@ -127,10 +128,11 @@ export default function PartStocktakeDetail({
|
||||
},
|
||||
{
|
||||
accessor: 'date',
|
||||
sortable: true
|
||||
sortable: false
|
||||
},
|
||||
{
|
||||
accessor: 'note'
|
||||
accessor: 'note',
|
||||
sortable: false
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
@@ -174,17 +176,15 @@ export default function PartStocktakeDetail({
|
||||
return {
|
||||
date: new Date(record.date).valueOf(),
|
||||
quantity: record.quantity,
|
||||
value_min: record.cost_min,
|
||||
value_max: record.cost_max
|
||||
value_min: Number.parseFloat(record.cost_min),
|
||||
value_max: Number.parseFloat(record.cost_max)
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
// Sort records to ensure correct date order
|
||||
records.sort((a, b) => {
|
||||
return records.sort((a, b) => {
|
||||
return a < b ? -1 : 1;
|
||||
});
|
||||
|
||||
return records;
|
||||
}, [table.records]);
|
||||
|
||||
// Calculate the date limits of the chart
|
||||
@@ -216,7 +216,8 @@ export default function PartStocktakeDetail({
|
||||
columns={tableColumns}
|
||||
props={{
|
||||
params: {
|
||||
part: partId
|
||||
part: partId,
|
||||
ordering: 'date'
|
||||
},
|
||||
rowActions: rowActions,
|
||||
tableActions: tableActions
|
||||
@@ -241,6 +242,12 @@ export default function PartStocktakeDetail({
|
||||
<ChartTooltip label={label} payload={payload} />
|
||||
)
|
||||
}}
|
||||
yAxisProps={{
|
||||
allowDataOverflow: false
|
||||
}}
|
||||
rightYAxisProps={{
|
||||
allowDataOverflow: false
|
||||
}}
|
||||
xAxisProps={{
|
||||
scale: 'time',
|
||||
type: 'number',
|
||||
|
||||
@@ -399,6 +399,17 @@ export default function SalesOrderDetail() {
|
||||
successMessage: t`Order placed on hold`
|
||||
});
|
||||
|
||||
const shipOrder = useCreateApiFormModal({
|
||||
url: apiUrl(ApiEndpoints.sales_order_complete, order.pk),
|
||||
title: t`Ship Sales Order`,
|
||||
onFormSuccess: refreshInstance,
|
||||
preFormWarning: t`Ship this order?`,
|
||||
successMessage: t`Order shipped`,
|
||||
fields: {
|
||||
accept_incomplete: {}
|
||||
}
|
||||
});
|
||||
|
||||
const completeOrder = useCreateApiFormModal({
|
||||
url: apiUrl(ApiEndpoints.sales_order_complete, order.pk),
|
||||
title: t`Complete Sales Order`,
|
||||
@@ -444,7 +455,7 @@ export default function SalesOrderDetail() {
|
||||
icon='deliver'
|
||||
hidden={!canShip}
|
||||
color='blue'
|
||||
onClick={completeOrder.open}
|
||||
onClick={shipOrder.open}
|
||||
/>,
|
||||
<PrimaryActionButton
|
||||
title={t`Complete Order`}
|
||||
@@ -510,6 +521,7 @@ export default function SalesOrderDetail() {
|
||||
{issueOrder.modal}
|
||||
{cancelOrder.modal}
|
||||
{holdOrder.modal}
|
||||
{shipOrder.modal}
|
||||
{completeOrder.modal}
|
||||
{editSalesOrder.modal}
|
||||
{duplicateSalesOrder.modal}
|
||||
|
||||
@@ -295,7 +295,7 @@ export default function SalesOrderShipmentDetail() {
|
||||
visible={!!shipment.delivery_date}
|
||||
/>
|
||||
];
|
||||
}, [shipment, shipmentQuery]);
|
||||
}, [isPending, shipment.deliveryDate, shipmentQuery.isFetching]);
|
||||
|
||||
const shipmentActions = useMemo(() => {
|
||||
const canEdit: boolean = user.hasChangePermission(
|
||||
|
||||
@@ -280,6 +280,8 @@ export default function Stock() {
|
||||
<BarcodeActionDropdown
|
||||
model={ModelType.stocklocation}
|
||||
pk={location.pk}
|
||||
hash={location?.barcode_hash}
|
||||
perm={user.hasChangeRole(UserRoles.stock_location)}
|
||||
actions={[
|
||||
{
|
||||
name: 'Scan in stock items',
|
||||
|
||||
@@ -659,14 +659,15 @@ export default function StockDetail() {
|
||||
});
|
||||
|
||||
const stockActions = useMemo(() => {
|
||||
const inStock =
|
||||
// Can this stock item be transferred to a different location?
|
||||
const canTransfer =
|
||||
user.hasChangeRole(UserRoles.stock) &&
|
||||
stockitem.quantity > 0 &&
|
||||
!stockitem.sales_order &&
|
||||
!stockitem.belongs_to &&
|
||||
!stockitem.customer &&
|
||||
!stockitem.consumed_by &&
|
||||
!stockitem.is_building;
|
||||
!stockitem.consumed_by;
|
||||
|
||||
const isBuilding = stockitem.is_building;
|
||||
|
||||
const serial = stockitem.serial;
|
||||
const serialized =
|
||||
@@ -697,7 +698,7 @@ export default function StockDetail() {
|
||||
{
|
||||
name: t`Count`,
|
||||
tooltip: t`Count stock`,
|
||||
hidden: serialized || !inStock,
|
||||
hidden: serialized || !canTransfer || isBuilding,
|
||||
icon: (
|
||||
<InvenTreeIcon icon='stocktake' iconProps={{ color: 'blue' }} />
|
||||
),
|
||||
@@ -708,7 +709,7 @@ export default function StockDetail() {
|
||||
{
|
||||
name: t`Add`,
|
||||
tooltip: t`Add Stock`,
|
||||
hidden: serialized || !inStock,
|
||||
hidden: serialized || !canTransfer || isBuilding,
|
||||
icon: <InvenTreeIcon icon='add' iconProps={{ color: 'green' }} />,
|
||||
onClick: () => {
|
||||
stockitem.pk && addStockItem.open();
|
||||
@@ -717,7 +718,11 @@ export default function StockDetail() {
|
||||
{
|
||||
name: t`Remove`,
|
||||
tooltip: t`Remove Stock`,
|
||||
hidden: serialized || !inStock,
|
||||
hidden:
|
||||
serialized ||
|
||||
!canTransfer ||
|
||||
isBuilding ||
|
||||
stockitem.quantity <= 0,
|
||||
icon: <InvenTreeIcon icon='remove' iconProps={{ color: 'red' }} />,
|
||||
onClick: () => {
|
||||
stockitem.pk && removeStockItem.open();
|
||||
@@ -726,7 +731,7 @@ export default function StockDetail() {
|
||||
{
|
||||
name: t`Transfer`,
|
||||
tooltip: t`Transfer Stock`,
|
||||
hidden: !inStock,
|
||||
hidden: !canTransfer,
|
||||
icon: (
|
||||
<InvenTreeIcon icon='transfer' iconProps={{ color: 'blue' }} />
|
||||
),
|
||||
@@ -738,8 +743,10 @@ export default function StockDetail() {
|
||||
name: t`Serialize`,
|
||||
tooltip: t`Serialize stock`,
|
||||
hidden:
|
||||
!inStock ||
|
||||
!canTransfer ||
|
||||
isBuilding ||
|
||||
serialized ||
|
||||
stockitem?.quantity != 1 ||
|
||||
stockitem?.part_detail?.trackable != true,
|
||||
icon: <InvenTreeIcon icon='serial' iconProps={{ color: 'blue' }} />,
|
||||
onClick: () => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { t } from '@lingui/macro';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { api } from '../App';
|
||||
import { ApiEndpoints } from '../enums/ApiEndpoints';
|
||||
import { generateUrl } from '../functions/urls';
|
||||
@@ -38,17 +40,29 @@ export const useIconState = create<IconState>()((set, get) => ({
|
||||
|
||||
await Promise.all(
|
||||
packs.data.map(async (pack: any) => {
|
||||
const fontName = `inventree-icon-font-${pack.prefix}`;
|
||||
const src = Object.entries(pack.fonts as Record<string, string>)
|
||||
.map(
|
||||
([format, url]) => `url(${generateUrl(url)}) format("${format}")`
|
||||
)
|
||||
.join(',\n');
|
||||
const font = new FontFace(fontName, `${src};`);
|
||||
await font.load();
|
||||
document.fonts.add(font);
|
||||
if (pack.prefix && pack.fonts) {
|
||||
const fontName = `inventree-icon-font-${pack.prefix}`;
|
||||
const src = Object.entries(pack.fonts as Record<string, string>)
|
||||
.map(
|
||||
([format, url]) => `url(${generateUrl(url)}) format("${format}")`
|
||||
)
|
||||
.join(',\n');
|
||||
const font = new FontFace(fontName, `${src};`);
|
||||
await font.load();
|
||||
document.fonts.add(font);
|
||||
return font;
|
||||
} else {
|
||||
console.error(
|
||||
"ERR: Icon package is missing 'prefix' or 'fonts' field"
|
||||
);
|
||||
showNotification({
|
||||
title: t`Error`,
|
||||
message: t`Error loading icon package from server`,
|
||||
color: 'red'
|
||||
});
|
||||
|
||||
return font;
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -56,7 +70,7 @@ export const useIconState = create<IconState>()((set, get) => ({
|
||||
hasLoaded: true,
|
||||
packages: packs.data,
|
||||
packagesMap: Object.fromEntries(
|
||||
packs.data.map((pack: any) => [pack.prefix, pack])
|
||||
packs.data?.map((pack: any) => [pack.prefix, pack])
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -574,6 +574,7 @@ export default function BuildOutputTable({
|
||||
props={{
|
||||
params: {
|
||||
part_detail: true,
|
||||
location_detail: true,
|
||||
tests: true,
|
||||
is_building: true,
|
||||
build: buildId
|
||||
|
||||
@@ -130,7 +130,9 @@ export function AttachmentTable({
|
||||
setIsUploading(true);
|
||||
|
||||
api
|
||||
.post(url, formData)
|
||||
.post(url, formData, {
|
||||
timeout: 30 * 1000
|
||||
})
|
||||
.then((response) => {
|
||||
notifications.show({
|
||||
title: t`File uploaded`,
|
||||
|
||||
@@ -90,10 +90,8 @@ test('Build Order - Basic Tests', async ({ page }) => {
|
||||
test('Build Order - Build Outputs', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.goto(`${baseUrl}/part/`);
|
||||
|
||||
// Navigate to the correct build order
|
||||
await page.getByRole('tab', { name: 'Manufacturing', exact: true }).click();
|
||||
await page.goto(`${baseUrl}/manufacturing/index/`);
|
||||
await page.getByRole('tab', { name: 'Build Orders', exact: true }).click();
|
||||
|
||||
// We have now loaded the "Build Order" table. Check for some expected texts
|
||||
await page.getByText('On Hold').first().waitFor();
|
||||
|
||||
@@ -149,35 +149,3 @@ test('Purchase Orders', async ({ page }) => {
|
||||
|
||||
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
|
||||
});
|
||||
|
||||
test('Purchase Orders - Barcodes', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.goto(`${baseUrl}/purchasing/purchase-order/13/detail`);
|
||||
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
|
||||
|
||||
// Display QR code
|
||||
await page.getByLabel('action-menu-barcode-actions').click();
|
||||
await page.getByLabel('action-menu-barcode-actions-view').click();
|
||||
await page.getByRole('img', { name: 'QR Code' }).waitFor();
|
||||
await page.getByRole('banner').getByRole('button').click();
|
||||
|
||||
// Link to barcode
|
||||
await page.getByLabel('action-menu-barcode-actions').click();
|
||||
await page.getByLabel('action-menu-barcode-actions-link-barcode').click();
|
||||
await page.getByRole('heading', { name: 'Link Barcode' }).waitFor();
|
||||
await page
|
||||
.getByPlaceholder('Scan barcode data here using')
|
||||
.fill('1234567890');
|
||||
await page.getByRole('button', { name: 'Link' }).click();
|
||||
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
|
||||
|
||||
// Unlink barcode
|
||||
await page.getByLabel('action-menu-barcode-actions').click();
|
||||
await page.getByLabel('action-menu-barcode-actions-unlink-barcode').click();
|
||||
await page.getByRole('heading', { name: 'Unlink Barcode' }).waitFor();
|
||||
await page.getByText('This will remove the link to').waitFor();
|
||||
await page.getByRole('button', { name: 'Unlink Barcode' }).click();
|
||||
await page.waitForTimeout(500);
|
||||
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
|
||||
});
|
||||
|
||||
@@ -8,23 +8,6 @@ test('Login - Basic Test', async ({ page }) => {
|
||||
// Check that the username is provided
|
||||
await page.getByText(user.username);
|
||||
|
||||
await expect(page).toHaveTitle(/^InvenTree/);
|
||||
|
||||
// Go to the dashboard
|
||||
await page.goto(baseUrl);
|
||||
await page.waitForURL('**/platform');
|
||||
|
||||
await page.getByText('InvenTree Demo Server -').waitFor();
|
||||
|
||||
// Check that the username is provided
|
||||
await page.getByText(user.username);
|
||||
|
||||
await expect(page).toHaveTitle(/^InvenTree/);
|
||||
|
||||
// Go to the dashboard
|
||||
await page.goto(baseUrl);
|
||||
await page.waitForURL('**/platform');
|
||||
|
||||
// Logout (via menu)
|
||||
await page.getByRole('button', { name: 'Ally Access' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Logout' }).click();
|
||||
|
||||
36
tasks.py
36
tasks.py
@@ -11,6 +11,7 @@ from pathlib import Path
|
||||
from platform import python_version
|
||||
from typing import Optional
|
||||
|
||||
import invoke
|
||||
from invoke import Collection, task
|
||||
from invoke.exceptions import UnexpectedExit
|
||||
|
||||
@@ -39,6 +40,19 @@ def info(*args):
|
||||
print(f'\033[94m{msg}\033[0m')
|
||||
|
||||
|
||||
def checkInvokeVersion():
|
||||
"""Check that the installed invoke version meets minimum requirements."""
|
||||
MIN_INVOKE_VERSION = '2.0.0'
|
||||
|
||||
min_version = tuple(map(int, MIN_INVOKE_VERSION.split('.')))
|
||||
invoke_version = tuple(map(int, invoke.__version__.split('.')))
|
||||
|
||||
if invoke_version < min_version:
|
||||
error(f'The installed invoke version ({invoke.__version__}) is not supported!')
|
||||
error(f'InvenTree requires invoke version {MIN_INVOKE_VERSION} or above')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def checkPythonVersion():
|
||||
"""Check that the installed python version meets minimum requirements.
|
||||
|
||||
@@ -63,6 +77,7 @@ def checkPythonVersion():
|
||||
|
||||
|
||||
if __name__ in ['__main__', 'tasks']:
|
||||
checkInvokeVersion()
|
||||
checkPythonVersion()
|
||||
|
||||
|
||||
@@ -904,13 +919,28 @@ def gunicorn(c, address='0.0.0.0:8000', workers=None):
|
||||
run(c, cmd, pty=True)
|
||||
|
||||
|
||||
@task(pre=[wait], help={'address': 'Server address:port (default=127.0.0.1:8000)'})
|
||||
def server(c, address='127.0.0.1:8000'):
|
||||
@task(
|
||||
pre=[wait],
|
||||
help={
|
||||
'address': 'Server address:port (default=127.0.0.1:8000)',
|
||||
'no_reload': 'Do not automatically reload the server in response to code changes',
|
||||
'no_threading': 'Disable multi-threading for the development server',
|
||||
},
|
||||
)
|
||||
def server(c, address='127.0.0.1:8000', no_reload=False, no_threading=False):
|
||||
"""Launch a (development) server using Django's in-built webserver.
|
||||
|
||||
Note: This is *not* sufficient for a production installation.
|
||||
"""
|
||||
manage(c, f'runserver {address}', pty=True)
|
||||
cmd = f'runserver {address}'
|
||||
|
||||
if no_reload:
|
||||
cmd += ' --noreload'
|
||||
|
||||
if no_threading:
|
||||
cmd += ' --nothreading'
|
||||
|
||||
manage(c, cmd, pty=True)
|
||||
|
||||
|
||||
@task(pre=[wait])
|
||||
|
||||
Reference in New Issue
Block a user