2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-17 18:26:32 +00:00

Merge branch 'master' of https://github.com/inventree/InvenTree into matmair/issue6281

This commit is contained in:
Matthias Mair
2024-04-22 20:14:43 +02:00
218 changed files with 84850 additions and 66643 deletions
.github/workflows
ProcfileRELEASE.mdbackportrc.jsoncodecov.yml
contrib/container
docs
src
backend
.eslintrc.yml
InvenTree
InvenTree
common
company
locale
bg
LC_MESSAGES
cs
LC_MESSAGES
da
LC_MESSAGES
de
LC_MESSAGES
el
LC_MESSAGES
en
LC_MESSAGES
es
LC_MESSAGES
es_MX
LC_MESSAGES
fa
LC_MESSAGES
fi
LC_MESSAGES
fr
LC_MESSAGES
he
LC_MESSAGES
hi
LC_MESSAGES
hu
LC_MESSAGES
id
LC_MESSAGES
it
LC_MESSAGES
ja
LC_MESSAGES
ko
LC_MESSAGES
lv
LC_MESSAGES
nl
LC_MESSAGES
no
LC_MESSAGES
pl
LC_MESSAGES
pt
LC_MESSAGES
ru
LC_MESSAGES
sk
LC_MESSAGES
sl
LC_MESSAGES
sr
LC_MESSAGES
sv
LC_MESSAGES
th
LC_MESSAGES
tr
LC_MESSAGES
vi
LC_MESSAGES
zh
LC_MESSAGES
zh_Hans
LC_MESSAGES
order
part
templates
users
eslint.config.jspackage-lock.jsonpackage.jsonrequirements-dev.txtrequirements.txt
frontend
eslint.config.cjspackage.json
src
App.tsx
components
contexts
defaults
enums
forms
functions
locales
pages
router.tsx
states
tables
views
tests
yarn.lock
tasks.py

@@ -9,15 +9,13 @@ on:
pull_request_target:
types: ["labeled", "closed"]
permissions:
contents: read
jobs:
backport:
name: Backport PR
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
if: |
github.event.pull_request.merged == true
&& contains(github.event.pull_request.labels.*.name, 'backport')
@@ -31,7 +29,6 @@ jobs:
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
auto_backport_label_prefix: backport-to-
add_original_reviewers: true
- name: Info log
if: ${{ success() }}

@@ -15,13 +15,12 @@ permissions:
contents: read
jobs:
check:
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INVENTREE_DB_NAME: './test_db.sqlite'
INVENTREE_DB_NAME: "./test_db.sqlite"
INVENTREE_DB_ENGINE: django.db.backends.sqlite3
INVENTREE_DEBUG: info
INVENTREE_MEDIA_ROOT: ./media

@@ -19,10 +19,14 @@ on:
push:
branches:
- 'master'
- "master"
pull_request:
branches:
- 'master'
- "master"
env:
requests_version: 2.31.0
pyyaml_version: 6.0.1
permissions:
contents: read
@@ -76,8 +80,8 @@ jobs:
python-version: ${{ env.python_version }}
- name: Version Check
run: |
pip install requests==2.31.0
pip install pyyaml==6.0.1
pip install requests==${{ env.requests_version }}
pip install pyyaml==${{ env.pyyaml_version }}
python3 .github/scripts/version_check.py
echo "git_commit_hash=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
echo "git_commit_date=$(git show -s --format=%ci)" >> $GITHUB_ENV
@@ -128,7 +132,7 @@ jobs:
uses: docker/setup-buildx-action@2b51285047da1547ffb1b2203d8be4c0af6b1f20 # pin@v3.2.0
- name: Set up cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@e1523de7571e31dbe865fd2e80c5c7c23ae71eb4 # pin@v3.4.0
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 # pin@v3.5.0
- name: Check if Dockerhub login is required
id: docker_login
run: |

@@ -4,15 +4,17 @@ name: QC
on:
push:
branches-ignore: [ 'l10*' ]
branches-ignore: ["l10*"]
pull_request:
branches-ignore: [ 'l10*' ]
branches-ignore: ["l10*"]
env:
python_version: 3.9
node_version: 18
# The OS version must be set per job
server_start_sleep: 60
requests_version: 2.31.0
pyyaml_version: 6.0.1
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INVENTREE_DB_ENGINE: sqlite3
@@ -63,12 +65,11 @@ jobs:
contains(github.event.pull_request.labels.*.name, 'dependency') ||
contains(github.event.pull_request.labels.*.name, 'full-run')
javascript:
name: Style - Classic UI [JS]
runs-on: ubuntu-20.04
needs: [ 'pre-commit' ]
needs: ["pre-commit"]
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
@@ -98,12 +99,12 @@ jobs:
uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # pin@v5.1.0
with:
python-version: ${{ env.python_version }}
cache: 'pip'
cache: "pip"
- name: Run pre-commit Checks
uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # pin@v3.0.1
- name: Check Version
run: |
pip install requests==2.31.0
pip install requests==${{ env.requests_version }}
python3 .github/scripts/version_check.py
mkdocs:
@@ -121,7 +122,7 @@ jobs:
python-version: ${{ env.python_version }}
- name: Check Config
run: |
pip install pyyaml==6.0.1
pip install pyyaml==${{ env.pyyaml_version }}
pip install -r docs/requirements.txt
python docs/ci/check_mkdocs_config.py
- name: Check Links
@@ -129,8 +130,8 @@ jobs:
with:
folder-path: docs
config-file: docs/mlc_config.json
check-modified-files-only: 'yes'
use-quiet-mode: 'yes'
check-modified-files-only: "yes"
use-quiet-mode: "yes"
schema:
name: Tests - API Schema Documentation
@@ -167,7 +168,7 @@ jobs:
- name: Download public schema
if: needs.paths-filter.outputs.api == 'false'
run: |
pip install requests==2.31.0 >/dev/null 2>&1
pip install requests==${{ env.requests_version }} >/dev/null 2>&1
version="$(python3 .github/scripts/version_check.py only_version 2>&1)"
echo "Version: $version"
url="https://raw.githubusercontent.com/inventree/schema/main/export/${version}/api.yaml"
@@ -186,7 +187,7 @@ jobs:
id: version
if: github.ref == 'refs/heads/master' && needs.paths-filter.outputs.api == 'true'
run: |
pip install requests==2.31.0 >/dev/null 2>&1
pip install requests==${{ env.requests_version }} >/dev/null 2>&1
version="$(python3 .github/scripts/version_check.py only_version 2>&1)"
echo "Version: $version"
echo "version=$version" >> "$GITHUB_OUTPUT"
@@ -213,7 +214,7 @@ jobs:
echo "Version: $version"
mkdir export/${version}
mv schema.yml export/${version}/api.yaml
- uses: stefanzweifel/git-auto-commit-action@8756aa072ef5b4a080af5dc8fef36c5d586e521d # v5.0.0
- uses: stefanzweifel/git-auto-commit-action@8621497c8c39c72f3e2a999a26b4ca1b5058a842 # v5.0.1
with:
commit_message: "Update API schema for ${version}"
@@ -221,7 +222,7 @@ jobs:
name: Tests - inventree-python
runs-on: ubuntu-20.04
needs: [ 'pre-commit', 'paths-filter' ]
needs: ["pre-commit", "paths-filter"]
if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.force == 'true'
env:
@@ -263,7 +264,7 @@ jobs:
name: Tests - DB [SQLite] + Coverage
runs-on: ubuntu-20.04
needs: [ 'pre-commit', 'paths-filter' ]
needs: ["pre-commit", "paths-filter"]
if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.force == 'true'
continue-on-error: true # continue if a step fails so that coverage gets pushed
@@ -300,7 +301,7 @@ jobs:
git-branch: ${{ github.ref }}
parallel: true
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v4.3.0
uses: codecov/codecov-action@84508663e988701840491b86de86b666e8a86bed # pin@v4.3.0
if: always()
with:
token: ${{ secrets.CODECOV_TOKEN }}
@@ -310,14 +311,14 @@ jobs:
postgres:
name: Tests - DB [PostgreSQL]
runs-on: ubuntu-20.04
needs: [ 'pre-commit', 'paths-filter' ]
needs: ["pre-commit", "paths-filter"]
if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.force == 'true'
env:
INVENTREE_DB_ENGINE: django.db.backends.postgresql
INVENTREE_DB_USER: inventree
INVENTREE_DB_PASSWORD: password
INVENTREE_DB_HOST: '127.0.0.1'
INVENTREE_DB_HOST: "127.0.0.1"
INVENTREE_DB_PORT: 5432
INVENTREE_DEBUG: info
INVENTREE_CACHE_HOST: localhost
@@ -355,7 +356,7 @@ jobs:
name: Tests - DB [MySQL]
runs-on: ubuntu-20.04
needs: [ 'pre-commit', 'paths-filter' ]
needs: ["pre-commit", "paths-filter"]
if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.force == 'true'
env:
@@ -363,7 +364,7 @@ jobs:
INVENTREE_DB_ENGINE: django.db.backends.mysql
INVENTREE_DB_USER: root
INVENTREE_DB_PASSWORD: password
INVENTREE_DB_HOST: '127.0.0.1'
INVENTREE_DB_HOST: "127.0.0.1"
INVENTREE_DB_PORT: 3306
INVENTREE_DEBUG: info
INVENTREE_PLUGINS_ENABLED: true
@@ -406,7 +407,7 @@ jobs:
INVENTREE_DB_NAME: inventree
INVENTREE_DB_USER: inventree
INVENTREE_DB_PASSWORD: password
INVENTREE_DB_HOST: '127.0.0.1'
INVENTREE_DB_HOST: "127.0.0.1"
INVENTREE_DB_PORT: 5432
INVENTREE_DEBUG: info
INVENTREE_PLUGINS_ENABLED: false
@@ -440,7 +441,7 @@ jobs:
git-commit: ${{ github.sha }}
git-branch: ${{ github.ref }}
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v4.3.0
uses: codecov/codecov-action@84508663e988701840491b86de86b666e8a86bed # pin@v4.3.0
if: always()
with:
token: ${{ secrets.CODECOV_TOKEN }}
@@ -507,7 +508,7 @@ jobs:
name: Tests - Platform UI
runs-on: ubuntu-20.04
timeout-minutes: 60
needs: [ 'pre-commit', 'paths-filter' ]
needs: ["pre-commit", "paths-filter"]
if: needs.paths-filter.outputs.frontend == 'true' || needs.paths-filter.outputs.force == 'true'
env:
INVENTREE_DB_ENGINE: sqlite3
@@ -526,6 +527,8 @@ jobs:
update: true
- name: Set up test data
run: invoke setup-test -i
- name: Rebuild thumbnails
run: invoke rebuild-thumbnails
- name: Install dependencies
run: inv frontend-compile
- name: Install Playwright Browsers
@@ -533,7 +536,7 @@ jobs:
- name: Run Playwright tests
id: tests
run: cd src/frontend && npx nyc playwright test
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # pin@v4
if: ${{ !cancelled() && steps.tests.outcome == 'failure' }}
with:
name: playwright-report
@@ -542,17 +545,8 @@ jobs:
- name: Report coverage
if: always()
run: cd src/frontend && npx nyc report --report-dir ./coverage --temp-dir .nyc_output --reporter=lcov --exclude-after-remap false
- name: Upload Coverage Report to Coveralls
if: always()
uses: coverallsapp/github-action@3dfc5567390f6fa9267c0ee9c251e4c8c3f18949 # pin@v2.2.3
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
flag-name: pui
git-commit: ${{ github.sha }}
git-branch: ${{ github.ref }}
parallel: true
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v4.3.0
uses: codecov/codecov-action@84508663e988701840491b86de86b666e8a86bed # pin@v4.3.0
if: always()
with:
token: ${{ secrets.CODECOV_TOKEN }}

@@ -5,11 +5,13 @@ on:
release:
types: [published]
env:
requests_version: 2.31.0
permissions:
contents: read
jobs:
stable:
runs-on: ubuntu-latest
env:
@@ -19,7 +21,7 @@ jobs:
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
- name: Version Check
run: |
pip install requests==2.31.0
pip install requests==${{ env.requests_version }}
python3 .github/scripts/version_check.py
- name: Push to Stable Branch
uses: ad-m/github-push-action@d91a481090679876dfc4178fef17f286781251df # pin@v0.8.0

@@ -10,7 +10,7 @@ on:
# To guarantee Maintained check is occasionally updated. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
schedule:
- cron: '32 0 * * 0'
- cron: "32 0 * * 0"
push:
branches: ["master"]
@@ -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@4355270be187e1b672a7a1c7c7bae5afdc1ab94a # v3.24.10
uses: github/codeql-action/upload-sarif@df5a14dc28094dc936e103b37d749c6628682b60 # v3.25.0
with:
sarif_file: results.sarif

@@ -3,14 +3,13 @@ name: Mark stale issues and pull requests
on:
schedule:
- cron: '24 11 * * *'
- cron: "24 11 * * *"
permissions:
contents: read
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
@@ -20,9 +19,9 @@ jobs:
- uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # pin@v9.0.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: 'This issue seems stale. Please react to show this is still important.'
stale-pr-message: 'This PR seems stale. Please react to show this is still important.'
stale-issue-label: 'inactive'
stale-pr-label: 'inactive'
start-date: '2022-01-01'
stale-issue-message: "This issue seems stale. Please react to show this is still important."
stale-pr-message: "This PR seems stale. Please react to show this is still important."
stale-issue-label: "inactive"
stale-pr-label: "inactive"
start-date: "2022-01-01"
exempt-all-milestones: true

@@ -14,14 +14,13 @@ permissions:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INVENTREE_DB_NAME: './test_db.sqlite'
INVENTREE_DB_NAME: "./test_db.sqlite"
INVENTREE_DB_ENGINE: django.db.backends.sqlite3
INVENTREE_DEBUG: info
INVENTREE_MEDIA_ROOT: ./media

@@ -1,7 +1,7 @@
# Web process: gunicorn
web: env/bin/gunicorn --chdir $APP_HOME/src/backend/InvenTree -c src/backend/InvenTree/gunicorn.conf.py InvenTree.wsgi -b 0.0.0.0:$PORT
# Worker process: qcluster
worker: env/bin/python src/backendInvenTree/manage.py qcluster
worker: env/bin/python src/backend/InvenTree/manage.py qcluster
# Invoke commands
invoke: echo "" | echo "" && . env/bin/activate && invoke
# CLI: Provided for backwards compatibility

@@ -4,7 +4,7 @@ Checklist of steps to perform at each code release
### Update Version String
Update `INVENTREE_SW_VERSION` in [version.py](https://github.com/inventree/InvenTree/blob/master/InvenTree/InvenTree/version.py)
Update `INVENTREE_SW_VERSION` in [version.py](https://github.com/inventree/InvenTree/blob/master/src/backend/InvenTree/InvenTree/version.py)
### Increment API Version

@@ -1,8 +0,0 @@
{
"repoOwner": "Oliver Walters",
"repoName": "InvenTree",
"targetBranchChoices": [],
"branchLabelMapping": {
"^backport-to-(.+)$": "$1"
}
}

@@ -20,7 +20,7 @@ flag_management:
carryforward: true
statuses:
- type: project
target: 50%
target: 40%
- name: pui
carryforward: true
statuses:

@@ -12,7 +12,7 @@ mysqlclient>=2.2.0
mariadb>=1.1.8
# gunicorn web server
gunicorn>=21.2.0
gunicorn>=22.0.0
# LDAP required packages
django-auth-ldap # Django integration for ldap auth

@@ -12,8 +12,7 @@ Run the following commands from the top-level project directory:
```
$ git clone https://github.com/inventree/inventree
$ cd inventree/docs
$ pip install -r requirements.txt
$ pip install -r docs/requirements.txt
```
## Serve Locally

Binary file not shown.

After

(image error) Size: 28 KiB

Binary file not shown.

After

(image error) Size: 56 KiB

Binary file not shown.

After

(image error) Size: 26 KiB

@@ -96,7 +96,7 @@ The HEAD of the "stable" branch represents the latest stable release code.
## API versioning
The [API version](https://github.com/inventree/InvenTree/blob/master/InvenTree/InvenTree/api_version.py) needs to be bumped every time when the API is changed.
The [API version](https://github.com/inventree/InvenTree/blob/master/src/backend/InvenTree/InvenTree/api_version.py) needs to be bumped every time when the API is changed.
## Environment

@@ -65,7 +65,7 @@ Additionally, add the following imports after the extended line.
#### Blocks
The page_base file is split into multiple sections called blocks. This allows you to implement sections of the webpage while getting many items like navbars, sidebars, and general layout provided for you.
The current default page base can be found [here](https://github.com/inventree/InvenTree/blob/master/InvenTree/templates/page_base.html). Look through this file to determine overridable blocks. The [stock app](https://github.com/inventree/InvenTree/tree/master/InvenTree/stock) offers a great example of implementing these blocks.
The current default page base can be found [here](https://github.com/inventree/InvenTree/blob/master/src/backend/InvenTree/templates/page_base.html). Look through this file to determine overridable blocks. The [stock app](https://github.com/inventree/InvenTree/tree/master/src/backend/InvenTree/stock) offers a great example of implementing these blocks.
!!! warning "Sidebar Block"
You may notice that implementing the `sidebar` block doesn't initially work. Be sure to enable the sidebar using JavaScript. This can be achieved by appending the following code, replacing `label` with a label of your choosing, to the end of your template file.

@@ -11,7 +11,7 @@ External companies are represented by the *Company* database model. Each company
- [Manufacturer](#manufacturers)
!!! tip Multi Purpose
A company may be allocated to multiple categories
A company may be allocated to multiple categories, for example, a company may be both a supplier and a customer.
### Edit Company
@@ -20,6 +20,20 @@ To edit a company, click on the <span class='fas fa-edit'>Edit Company</span> ic
!!! warning "Permission Required"
The edit button will not be available to users who do not have the required permissions to edit the company
### Disable Company
Rather than deleting a company, it is possible to disable it. This will prevent the company from being used in new orders, but will not remove it from the database. Additionally, any existing orders associated with the company (and other linked items such as supplier parts, for a supplier) will remain intact. Unless the company is re-enabled, it will not be available for selection in new orders.
It is recommended to disable a company rather than deleting it, as this will preserve the integrity of historical data.
To disable a company, simply edit the company details and set the `active` attribute to `False`:
{% with id="company_disable", url="order/company_disable.png", description="Disable Company" %}
{% include "img.html" %}
{% endwith %}
To re-enable a company, simply follow the same process and set the `active` attribute to `True`.
### Delete Company
To delete a company, click on the <span class='fas fa-trash-alt'></span> icon under the actions menu. Confirm the deletion using the checkbox then click on <span class="badge inventree confirm">Submit</span>
@@ -193,6 +207,24 @@ To edit a supplier part, first access the supplier part detail page with one of
After the supplier part details are loaded, click on the <span class='fas fa-edit'></span> icon next to the supplier part image. Edit the supplier part information then click on <span class="badge inventree confirm">Submit</span>
#### Disable Supplier Part
Supplier parts can be individually disabled - for example, if a supplier part is no longer available for purchase. By disabling the part in the InvenTree system, it will no longer be available for selection in new purchase orders. However, any existing purchase orders which reference the supplier part will remain intact.
The "active" status of a supplier part is clearly visible within the user interface:
{% with id="supplier_part_disable", url="order/disable_supplier_part.png", description="Disable Supplier Part" %}
{% include "img.html" %}
{% endwith %}
To change the "active" status of a supplier part, simply edit the supplier part details and set the `active` attribute:
{% with id="supplier_part_disable_edit", url="order/disable_supplier_part_edit.png", description="Disable Supplier Part" %}
{% include "img.html" %}
{% endwith %}
It is recommended to disable a supplier part rather than deleting it, as this will preserve the integrity of historical data.
#### Delete Supplier Part
To delete a supplier part, first access the supplier part detail page like in the [Edit Supplier Part](#edit-supplier-part) section.

@@ -13,4 +13,4 @@ You can use all content variables from the [StockLocation](./context_variables.m
A default report template is provided out of the box, which can be used as a starting point for developing custom return order report templates.
View the [source code](https://github.com/inventree/InvenTree/blob/master/InvenTree/report/templates/report/inventree_slr_report.html) for the default stock location report template.
View the [source code](https://github.com/inventree/InvenTree/blob/master/src/backend/InvenTree/report/templates/report/inventree_slr_report.html) for the default stock location report template.

@@ -8,6 +8,9 @@ Backup functionality is provided natively using the [django-dbbackup library](ht
Note that a *backup* operation is not the same as [migrating data](./migrate.md). While data *migration* exports data into a database-agnostic JSON file, *backup* exports a native database file and media file archive.
!!! warning "Database Version"
When performing backup and restore operations, it is *imperative* that you are running from the same installed version of InvenTree. Different InvenTree versions may have different database schemas, which render backup / restore operations incompatible.
## Configuration
The following configuration options are available for backup:
@@ -22,22 +25,31 @@ The following configuration options are available for backup:
If you want to use an external storage provider, extra configuration is required. As a starting point, refer to the [django-dbbackup documentation](https://django-dbbackup.readthedocs.io/en/master/storage.html).
Specific storage configuration options are specified using the `backup_options` dict (in the [configuration file](./config.md)).
Specific storage configuration options are specified using the `backup_options` dict (in the [configuration file](./config.md#backup-file-storage)).
## Perform Backup
#### Manual Backup
To perform a manual backup operation, run the following command from the shell:
To perform a basic manual backup operation, run the following command from the shell:
```
invoke backup
```
This will perform backup operation with the default parameters. To see all available backup options, run:
```
invoke backup --help
```
### Backup During Update
When performing an update of your InvenTree installation - via either [docker](./docker.md) or [bare metal](./install.md) - a backup operation is automatically performed.
!!! info "Skip Backup Step"
You can opt to skip the backup step during the update process by adding the `--skip-backup` option.
### Daily Backup
If desired, InvenTree can be configured to perform automated daily backups. The run-time setting to control this is found in the *Server Configuration* tab.
@@ -56,3 +68,16 @@ To restore from a previous backup, run the following command from the shell (wit
```
invoke restore
```
To see all available options for restore, run:
```
invoke restore --help
```
## Advanced Usage
Not all functionality of the db-backup library is exposed by default. For advanced usage (not covered by the documentation above), refer to the [django-dbbackup commands documentation](https://django-dbbackup.readthedocs.io/en/master/commands.html).
!!! warning "Advanced Users Only"
Any advanced usage assumes some underlying knowledge of django, and is not documented here.

@@ -31,9 +31,9 @@ The following files required for this setup are provided with the InvenTree sour
| Filename | Description |
| --- | --- |
| [docker-compose.yml](https://github.com/inventree/InvenTree/blob/master/contrib/container/docker-compose.yml) | The docker compose script |
| [.env](https://github.com/inventree/InvenTree/blob/master/contrib/container/.env) | Environment variables |
| [Caddyfile](https://github.com/inventree/InvenTree/blob/master/contrib/container/Caddyfile) | Caddy configuration file |
| [docker-compose.yml](https://raw.githubusercontent.com/inventree/InvenTree/master/contrib/container/docker-compose.yml)| The docker compose script |
| [.env](https://raw.githubusercontent.com/inventree/InvenTree/master/contrib/container/.env) | Environment variables |
| [Caddyfile](https://raw.githubusercontent.com/inventree/InvenTree/master/contrib/container/Caddyfile) | Caddy configuration file |
Download these files to a directory on your local machine.

@@ -1,27 +0,0 @@
env:
commonjs: false
browser: true
es2021: true
jquery: true
extends:
- eslint:recommended
parserOptions:
ecmaVersion: 12
rules:
no-var: off
guard-for-in: off
no-trailing-spaces: off
camelcase: off
padded-blocks: off
prefer-const: off
max-len: off
require-jsdoc: off
valid-jsdoc: off
no-multiple-empty-lines: off
comma-dangle: off
no-unused-vars: off
no-useless-escape: off
prefer-spread: off
indent:
- error
- 4

@@ -1,11 +1,28 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 186
INVENTREE_API_VERSION = 190
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v190 - 2024-04-19 : https://github.com/inventree/InvenTree/pull/7024
- Adds "active" field to the Company API endpoints
- Allow company list to be filtered by "active" status
v189 - 2024-04-19 : https://github.com/inventree/InvenTree/pull/7066
- Adds "currency" field to CompanyBriefSerializer class
v188 - 2024-04-16 : https://github.com/inventree/InvenTree/pull/6970
- Adds session authentication support for the API
- Improvements for login / logout endpoints for better support of React web interface
v187 - 2024-04-10 : https://github.com/inventree/InvenTree/pull/6985
- Allow Part list endpoint to be sorted by pricing_min and pricing_max values
- Allow BomItem list endpoint to be sorted by pricing_min and pricing_max values
- Allow InternalPrice and SalePrice endpoints to be sorted by quantity
- Adds total pricing values to BomItem serializer
v186 - 2024-03-26 : https://github.com/inventree/InvenTree/pull/6855
- Adds license information to the API

@@ -489,10 +489,18 @@ if DEBUG:
'rest_framework.renderers.BrowsableAPIRenderer'
)
# dj-rest-auth
# JWT switch
USE_JWT = get_boolean_setting('INVENTREE_USE_JWT', 'use_jwt', False)
REST_USE_JWT = USE_JWT
# dj-rest-auth
REST_AUTH = {
'SESSION_LOGIN': True,
'TOKEN_MODEL': 'users.models.ApiToken',
'TOKEN_CREATOR': 'users.models.default_create_token',
'USE_JWT': USE_JWT,
}
OLD_PASSWORD_FIELD_ENABLED = True
REST_AUTH_REGISTER_SERIALIZERS = {
'REGISTER_SERIALIZER': 'InvenTree.forms.CustomRegisterSerializer'
@@ -507,6 +515,7 @@ if USE_JWT:
)
INSTALLED_APPS.append('rest_framework_simplejwt')
# WSGI default setting
WSGI_APPLICATION = 'InvenTree.wsgi.application'
@@ -1075,20 +1084,30 @@ CSRF_TRUSTED_ORIGINS = get_setting(
if SITE_URL and SITE_URL not in CSRF_TRUSTED_ORIGINS:
CSRF_TRUSTED_ORIGINS.append(SITE_URL)
if not TESTING and len(CSRF_TRUSTED_ORIGINS) == 0:
if DEBUG:
logger.warning(
'No CSRF_TRUSTED_ORIGINS specified. Defaulting to http://* for debug mode. This is not recommended for production use'
)
CSRF_TRUSTED_ORIGINS = ['http://*']
for origin in [
'http://localhost',
'http://*.localhost' 'http://*localhost:8000',
'http://*localhost:5173',
]:
if origin not in CSRF_TRUSTED_ORIGINS:
CSRF_TRUSTED_ORIGINS.append(origin)
elif isInMainThread():
if not TESTING and len(CSRF_TRUSTED_ORIGINS) == 0:
if isInMainThread():
# Server thread cannot run without CSRF_TRUSTED_ORIGINS
logger.error(
'No CSRF_TRUSTED_ORIGINS specified. Please provide a list of trusted origins, or specify INVENTREE_SITE_URL'
)
sys.exit(-1)
# Additional CSRF settings
CSRF_HEADER_NAME = 'HTTP_X_CSRFTOKEN'
CSRF_COOKIE_NAME = 'csrftoken'
CSRF_COOKIE_SAMESITE = 'Lax'
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = 'Lax'
USE_X_FORWARDED_HOST = get_boolean_setting(
'INVENTREE_USE_X_FORWARDED_HOST',
config_key='use_x_forwarded_host',

@@ -160,6 +160,7 @@ apipatterns = [
SocialAccountDisconnectView.as_view(),
name='social_account_disconnect',
),
path('login/', users.api.Login.as_view(), name='api-login'),
path('logout/', users.api.Logout.as_view(), name='api-logout'),
path(
'login-redirect/',

@@ -269,7 +269,7 @@ class FileManagementFormView(MultiStepFormView):
for idx, item in row_data.items():
column_data = {
'name': self.column_names[idx],
'guess': self.column_selections[idx],
'guess': self.column_selections.get(idx, ''),
}
cell_data = {'cell': item, 'idx': idx, 'column': column_data}

@@ -2,6 +2,7 @@
from django.db.models import Q
from django.urls import include, path, re_path
from django.utils.translation import gettext_lazy as _
from django_filters import rest_framework as rest_filters
@@ -58,11 +59,17 @@ class CompanyList(ListCreateAPI):
filter_backends = SEARCH_ORDER_FILTER
filterset_fields = ['is_customer', 'is_manufacturer', 'is_supplier', 'name']
filterset_fields = [
'is_customer',
'is_manufacturer',
'is_supplier',
'name',
'active',
]
search_fields = ['name', 'description', 'website']
ordering_fields = ['name', 'parts_supplied', 'parts_manufactured']
ordering_fields = ['active', 'name', 'parts_supplied', 'parts_manufactured']
ordering = 'name'
@@ -153,7 +160,13 @@ class ManufacturerPartFilter(rest_filters.FilterSet):
fields = ['manufacturer', 'MPN', 'part', 'tags__name', 'tags__slug']
# Filter by 'active' status of linked part
active = rest_filters.BooleanFilter(field_name='part__active')
part_active = rest_filters.BooleanFilter(
field_name='part__active', label=_('Part is Active')
)
manufacturer_active = rest_filters.BooleanFilter(
field_name='manufacturer__active', label=_('Manufacturer is Active')
)
class ManufacturerPartList(ListCreateDestroyAPIView):
@@ -301,8 +314,16 @@ class SupplierPartFilter(rest_filters.FilterSet):
'tags__slug',
]
active = rest_filters.BooleanFilter(label=_('Supplier Part is Active'))
# Filter by 'active' status of linked part
active = rest_filters.BooleanFilter(field_name='part__active')
part_active = rest_filters.BooleanFilter(
field_name='part__active', label=_('Internal Part is Active')
)
supplier_active = rest_filters.BooleanFilter(
field_name='supplier__active', label=_('Supplier is Active')
)
# Filter by the 'MPN' of linked manufacturer part
MPN = rest_filters.CharFilter(
@@ -378,6 +399,7 @@ class SupplierPartList(ListCreateDestroyAPIView):
'part',
'supplier',
'manufacturer',
'active',
'MPN',
'packaging',
'pack_quantity',
@@ -468,9 +490,13 @@ class SupplierPriceBreakList(ListCreateAPI):
return self.serializer_class(*args, **kwargs)
filter_backends = ORDER_FILTER
filter_backends = SEARCH_ORDER_FILTER_ALIAS
ordering_fields = ['quantity']
ordering_fields = ['quantity', 'supplier', 'SKU', 'price']
search_fields = ['part__SKU', 'part__supplier__name']
ordering_field_aliases = {'supplier': 'part__supplier__name', 'SKU': 'part__SKU'}
ordering = 'quantity'

@@ -0,0 +1,23 @@
# Generated by Django 4.2.11 on 2024-04-15 14:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('company', '0068_auto_20231120_1108'),
]
operations = [
migrations.AddField(
model_name='company',
name='active',
field=models.BooleanField(default=True, help_text='Is this company active?', verbose_name='Active'),
),
migrations.AddField(
model_name='supplierpart',
name='active',
field=models.BooleanField(default=True, help_text='Is this supplier part active?', verbose_name='Active'),
),
]

@@ -81,6 +81,7 @@ class Company(
link: Secondary URL e.g. for link to internal Wiki page
image: Company image / logo
notes: Extra notes about the company
active: boolean value, is this company active
is_customer: boolean value, is this company a customer
is_supplier: boolean value, is this company a supplier
is_manufacturer: boolean value, is this company a manufacturer
@@ -155,6 +156,10 @@ class Company(
verbose_name=_('Image'),
)
active = models.BooleanField(
default=True, verbose_name=_('Active'), help_text=_('Is this company active?')
)
is_customer = models.BooleanField(
default=False,
verbose_name=_('is customer'),
@@ -654,6 +659,7 @@ class SupplierPart(
part: Link to the master Part (Obsolete)
source_item: The sourcing item linked to this SupplierPart instance
supplier: Company that supplies this SupplierPart object
active: Boolean value, is this supplier part active
SKU: Stock keeping unit (supplier part number)
link: Link to external website for this supplier part
description: Descriptive notes field
@@ -802,6 +808,12 @@ class SupplierPart(
help_text=_('Supplier stock keeping unit'),
)
active = models.BooleanField(
default=True,
verbose_name=_('Active'),
help_text=_('Is this supplier part active?'),
)
manufacturer_part = models.ForeignKey(
ManufacturerPart,
on_delete=models.CASCADE,

@@ -42,9 +42,16 @@ class CompanyBriefSerializer(InvenTreeModelSerializer):
"""Metaclass options."""
model = Company
fields = ['pk', 'url', 'name', 'description', 'image', 'thumbnail']
url = serializers.CharField(source='get_absolute_url', read_only=True)
fields = [
'pk',
'active',
'name',
'description',
'image',
'thumbnail',
'currency',
]
read_only_fields = ['currency']
image = InvenTreeImageSerializerField(read_only=True)
@@ -116,6 +123,7 @@ class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer):
'contact',
'link',
'image',
'active',
'is_customer',
'is_manufacturer',
'is_supplier',
@@ -306,6 +314,7 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer):
'description',
'in_stock',
'link',
'active',
'manufacturer',
'manufacturer_detail',
'manufacturer_part',
@@ -369,8 +378,9 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer):
self.fields.pop('pretty_name')
# Annotated field showing total in-stock quantity
in_stock = serializers.FloatField(read_only=True)
available = serializers.FloatField(required=False)
in_stock = serializers.FloatField(read_only=True, label=_('In Stock'))
available = serializers.FloatField(required=False, label=_('Available'))
pack_quantity_native = serializers.FloatField(read_only=True)

@@ -10,6 +10,12 @@
{% block heading %}
{% trans "Company" %}: {{ company.name }}
{% if not company.active %}
&ensp;
<div class='badge rounded-pill bg-danger'>
{% trans 'Inactive' %}
</div>
{% endif %}
{% endblock heading %}
{% block actions %}

@@ -5,6 +5,7 @@ from django.urls import reverse
from rest_framework import status
from InvenTree.unit_test import InvenTreeAPITestCase
from part.models import Part
from .models import Address, Company, Contact, ManufacturerPart, SupplierPart
@@ -131,6 +132,32 @@ class CompanyTest(InvenTreeAPITestCase):
self.assertTrue('currency' in response.data)
def test_company_active(self):
"""Test that the 'active' value and filter works."""
Company.objects.filter(active=False).update(active=True)
n = Company.objects.count()
url = reverse('api-company-list')
self.assertEqual(
len(self.get(url, data={'active': True}, expected_code=200).data), n
)
self.assertEqual(
len(self.get(url, data={'active': False}, expected_code=200).data), 0
)
# Set one company to inactive
c = Company.objects.first()
c.active = False
c.save()
self.assertEqual(
len(self.get(url, data={'active': True}, expected_code=200).data), n - 1
)
self.assertEqual(
len(self.get(url, data={'active': False}, expected_code=200).data), 1
)
class ContactTest(InvenTreeAPITestCase):
"""Tests for the Contact models."""
@@ -528,6 +555,50 @@ class SupplierPartTest(InvenTreeAPITestCase):
self.assertEqual(sp.available, 999)
self.assertIsNotNone(sp.availability_updated)
def test_active(self):
"""Test that 'active' status filtering works correctly."""
url = reverse('api-supplier-part-list')
# Create a new company, which is inactive
company = Company.objects.create(
name='Inactive Company', is_supplier=True, active=False
)
part = Part.objects.filter(purchaseable=True).first()
# Create some new supplier part objects, *some* of which are inactive
for idx in range(10):
SupplierPart.objects.create(
part=part,
supplier=company,
SKU=f'CMP-{company.pk}-SKU-{idx}',
active=(idx % 2 == 0),
)
n = SupplierPart.objects.count()
# List *all* supplier parts
self.assertEqual(len(self.get(url, data={}, expected_code=200).data), n)
# List only active supplier parts (all except 5 from the new supplier)
self.assertEqual(
len(self.get(url, data={'active': True}, expected_code=200).data), n - 5
)
# List only from 'active' suppliers (all except this new supplier)
self.assertEqual(
len(self.get(url, data={'supplier_active': True}, expected_code=200).data),
n - 10,
)
# List active parts from inactive suppliers (only 5 from the new supplier)
response = self.get(
url, data={'supplier_active': False, 'active': True}, expected_code=200
)
self.assertEqual(len(response.data), 5)
for result in response.data:
self.assertEqual(result['supplier'], company.pk)
class CompanyMetadataAPITest(InvenTreeAPITestCase):
"""Unit tests for the various metadata endpoints of API."""

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@@ -154,11 +154,11 @@ class LineItemFilter(rest_filters.FilterSet):
# Filter by order status
order_status = rest_filters.NumberFilter(
label='order_status', field_name='order__status'
label=_('Order Status'), field_name='order__status'
)
has_pricing = rest_filters.BooleanFilter(
label='Has Pricing', method='filter_has_pricing'
label=_('Has Pricing'), method='filter_has_pricing'
)
def filter_has_pricing(self, queryset, name, value):
@@ -425,9 +425,38 @@ class PurchaseOrderLineItemFilter(LineItemFilter):
price_field = 'purchase_price'
model = models.PurchaseOrderLineItem
fields = ['order', 'part']
fields = []
pending = rest_filters.BooleanFilter(label='pending', method='filter_pending')
order = rest_filters.ModelChoiceFilter(
queryset=models.PurchaseOrder.objects.all(),
field_name='order',
label=_('Order'),
)
order_complete = rest_filters.BooleanFilter(
label=_('Order Complete'), method='filter_order_complete'
)
def filter_order_complete(self, queryset, name, value):
"""Filter by whether the order is 'complete' or not."""
if str2bool(value):
return queryset.filter(order__status=PurchaseOrderStatus.COMPLETE.value)
return queryset.exclude(order__status=PurchaseOrderStatus.COMPLETE.value)
part = rest_filters.ModelChoiceFilter(
queryset=SupplierPart.objects.all(), field_name='part', label=_('Supplier Part')
)
base_part = rest_filters.ModelChoiceFilter(
queryset=Part.objects.filter(purchaseable=True),
field_name='part__part',
label=_('Internal Part'),
)
pending = rest_filters.BooleanFilter(
method='filter_pending', label=_('Order Pending')
)
def filter_pending(self, queryset, name, value):
"""Filter by "pending" status (order status = pending)."""
@@ -435,7 +464,9 @@ class PurchaseOrderLineItemFilter(LineItemFilter):
return queryset.filter(order__status__in=PurchaseOrderStatusGroups.OPEN)
return queryset.exclude(order__status__in=PurchaseOrderStatusGroups.OPEN)
received = rest_filters.BooleanFilter(label='received', method='filter_received')
received = rest_filters.BooleanFilter(
label=_('Items Received'), method='filter_received'
)
def filter_received(self, queryset, name, value):
"""Filter by lines which are "received" (or "not" received).
@@ -542,25 +573,6 @@ class PurchaseOrderLineItemList(
serializer.data, status=status.HTTP_201_CREATED, headers=headers
)
def filter_queryset(self, queryset):
"""Additional filtering options."""
params = self.request.query_params
queryset = super().filter_queryset(queryset)
base_part = params.get('base_part', None)
if base_part:
try:
base_part = Part.objects.get(pk=base_part)
queryset = queryset.filter(part__part=base_part)
except (ValueError, Part.DoesNotExist):
pass
return queryset
def download_queryset(self, queryset, export_format):
"""Download the requested queryset as a file."""
dataset = PurchaseOrderLineItemResource().export(queryset=queryset)
@@ -577,6 +589,8 @@ class PurchaseOrderLineItemList(
'MPN': 'part__manufacturer_part__MPN',
'SKU': 'part__SKU',
'part_name': 'part__part__name',
'order': 'order__reference',
'complete_date': 'order__complete_date',
}
ordering_fields = [
@@ -589,6 +603,8 @@ class PurchaseOrderLineItemList(
'SKU',
'total_price',
'target_date',
'order',
'complete_date',
]
search_fields = [
@@ -791,7 +807,15 @@ class SalesOrderLineItemFilter(LineItemFilter):
price_field = 'sale_price'
model = models.SalesOrderLineItem
fields = ['order', 'part']
fields = []
order = rest_filters.ModelChoiceFilter(
queryset=models.SalesOrder.objects.all(), field_name='order', label=_('Order')
)
part = rest_filters.ModelChoiceFilter(
queryset=Part.objects.all(), field_name='part', label=_('Part')
)
completed = rest_filters.BooleanFilter(label='completed', method='filter_completed')
@@ -806,6 +830,17 @@ class SalesOrderLineItemFilter(LineItemFilter):
return queryset.filter(q)
return queryset.exclude(q)
order_complete = rest_filters.BooleanFilter(
label=_('Order Complete'), method='filter_order_complete'
)
def filter_order_complete(self, queryset, name, value):
"""Filter by whether the order is 'complete' or not."""
if str2bool(value):
return queryset.filter(order__status__in=SalesOrderStatusGroups.COMPLETE)
return queryset.exclude(order__status__in=SalesOrderStatusGroups.COMPLETE)
class SalesOrderLineItemMixin:
"""Mixin class for SalesOrderLineItem endpoints."""
@@ -862,9 +897,24 @@ class SalesOrderLineItemList(SalesOrderLineItemMixin, APIDownloadMixin, ListCrea
return DownloadFile(filedata, filename)
filter_backends = SEARCH_ORDER_FILTER
filter_backends = SEARCH_ORDER_FILTER_ALIAS
ordering_fields = ['part__name', 'quantity', 'reference', 'target_date']
ordering_fields = [
'customer',
'order',
'part',
'part__name',
'quantity',
'reference',
'sale_price',
'target_date',
]
ordering_field_aliases = {
'customer': 'order__customer__name',
'part': 'part__name',
'order': 'order__reference',
}
search_fields = ['part__name', 'quantity', 'reference']

@@ -1459,9 +1459,11 @@ class PurchaseOrderLineItem(OrderLineItem):
def update_pricing(self):
"""Update pricing information based on the supplier part data."""
if self.part:
price = self.part.get_price(self.quantity)
price = self.part.get_price(
self.quantity, currency=self.purchase_price_currency
)
if price is None:
if price is None or self.quantity == 0:
return
self.purchase_price = Decimal(price) / Decimal(self.quantity)

@@ -38,7 +38,6 @@ from InvenTree.helpers import (
is_ajax,
isNull,
str2bool,
str2int,
)
from InvenTree.mixins import (
CreateAPI,
@@ -386,9 +385,10 @@ class PartSalePriceList(ListCreateAPI):
queryset = PartSellPriceBreak.objects.all()
serializer_class = part_serializers.PartSalePriceSerializer
filter_backends = [DjangoFilterBackend]
filter_backends = SEARCH_ORDER_FILTER
filterset_fields = ['part']
ordering_fields = ['quantity', 'price']
ordering = 'quantity'
class PartInternalPriceDetail(RetrieveUpdateDestroyAPI):
@@ -405,9 +405,10 @@ class PartInternalPriceList(ListCreateAPI):
serializer_class = part_serializers.PartInternalPriceSerializer
permission_required = 'roles.sales_order.show'
filter_backends = [DjangoFilterBackend]
filter_backends = SEARCH_ORDER_FILTER
filterset_fields = ['part']
ordering_fields = ['quantity', 'price']
ordering = 'quantity'
class PartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
@@ -1407,8 +1408,17 @@ class PartList(PartMixin, APIDownloadMixin, ListCreateAPI):
'category',
'last_stocktake',
'units',
'pricing_min',
'pricing_max',
'pricing_updated',
]
ordering_field_aliases = {
'pricing_min': 'pricing_data__overall_min',
'pricing_max': 'pricing_data__overall_max',
'pricing_updated': 'pricing_data__updated',
}
# Default ordering
ordering = 'name'
@@ -1939,9 +1949,19 @@ class BomList(BomMixin, ListCreateDestroyAPIView):
'inherited',
'optional',
'consumable',
'pricing_min',
'pricing_max',
'pricing_min_total',
'pricing_max_total',
'pricing_updated',
]
ordering_field_aliases = {'sub_part': 'sub_part__name'}
ordering_field_aliases = {
'sub_part': 'sub_part__name',
'pricing_min': 'sub_part__pricing_data__overall_min',
'pricing_max': 'sub_part__pricing_data__overall_max',
'pricing_updated': 'sub_part__pricing_data__updated',
}
class BomDetail(BomMixin, RetrieveUpdateDestroyAPI):

@@ -616,6 +616,7 @@ class PartSerializer(
'virtual',
'pricing_min',
'pricing_max',
'pricing_updated',
'responsible',
# Annotated fields
'allocated_to_build_orders',
@@ -678,6 +679,7 @@ class PartSerializer(
if not pricing:
self.fields.pop('pricing_min')
self.fields.pop('pricing_max')
self.fields.pop('pricing_updated')
def get_api_url(self):
"""Return the API url associated with this serializer."""
@@ -843,6 +845,9 @@ class PartSerializer(
pricing_max = InvenTree.serializers.InvenTreeMoneySerializer(
source='pricing_data.overall_max', allow_null=True, read_only=True
)
pricing_updated = serializers.DateTimeField(
source='pricing_data.updated', allow_null=True, read_only=True
)
parameters = PartParameterSerializer(many=True, read_only=True)
@@ -1413,6 +1418,9 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
'part_detail',
'pricing_min',
'pricing_max',
'pricing_min_total',
'pricing_max_total',
'pricing_updated',
'quantity',
'reference',
'sub_part',
@@ -1451,6 +1459,9 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
if not pricing:
self.fields.pop('pricing_min')
self.fields.pop('pricing_max')
self.fields.pop('pricing_min_total')
self.fields.pop('pricing_max_total')
self.fields.pop('pricing_updated')
quantity = InvenTree.serializers.InvenTreeDecimalField(required=True)
@@ -1481,10 +1492,22 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
# Cached pricing fields
pricing_min = InvenTree.serializers.InvenTreeMoneySerializer(
source='sub_part.pricing.overall_min', allow_null=True, read_only=True
source='sub_part.pricing_data.overall_min', allow_null=True, read_only=True
)
pricing_max = InvenTree.serializers.InvenTreeMoneySerializer(
source='sub_part.pricing.overall_max', allow_null=True, read_only=True
source='sub_part.pricing_data.overall_max', allow_null=True, read_only=True
)
pricing_min_total = InvenTree.serializers.InvenTreeMoneySerializer(
allow_null=True, read_only=True
)
pricing_max_total = InvenTree.serializers.InvenTreeMoneySerializer(
allow_null=True, read_only=True
)
pricing_updated = serializers.DateTimeField(
source='sub_part.pricing_data.updated', allow_null=True, read_only=True
)
# Annotated fields for available stock
@@ -1504,6 +1527,7 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
queryset = queryset.prefetch_related('sub_part')
queryset = queryset.prefetch_related('sub_part__category')
queryset = queryset.prefetch_related('sub_part__pricing_data')
queryset = queryset.prefetch_related(
'sub_part__stock_items',
@@ -1531,6 +1555,18 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
available_stock = total_stock - build_order_allocations - sales_order_allocations
"""
# Annotate with the 'total pricing' information based on unit pricing and quantity
queryset = queryset.annotate(
pricing_min_total=ExpressionWrapper(
F('quantity') * F('sub_part__pricing_data__overall_min'),
output_field=models.DecimalField(),
),
pricing_max_total=ExpressionWrapper(
F('quantity') * F('sub_part__pricing_data__overall_max'),
output_field=models.DecimalField(),
),
)
ref = 'sub_part__'
# Annotate with the total "on order" amount for the sub-part

@@ -21,12 +21,15 @@ function activatePanel(label, panel_name, options={}) {
$('.panel-visible').hide();
$('.panel-visible').removeClass('panel-visible');
// Remove illegal chars
panel_name = panel_name.replace('/', '');
// Find the target panel
var panel = `#panel-${panel_name}`;
var select = `#select-${panel_name}`;
// Check that the selected panel (and select) exist
if ($(panel).length && $(select).length) {
if ($(panel).exists() && $(panel).length && $(select).length) {
// Yep, both are displayed
} else {
// Either the select or the panel are not displayed!

@@ -426,7 +426,8 @@ function companyFormFields() {
},
is_supplier: {},
is_manufacturer: {},
is_customer: {}
is_customer: {},
active: {},
};
}
@@ -517,6 +518,15 @@ function loadCompanyTable(table, url, options={}) {
field: 'description',
title: '{% trans "Description" %}',
},
{
field: 'active',
title: '{% trans "Active" %}',
sortable: true,
switchable: true,
formatter: function(value) {
return yesNoLabel(value);
}
},
{
field: 'website',
title: '{% trans "Website" %}',

@@ -1755,7 +1755,7 @@ function loadPurchaseOrderTable(table, options) {
sortable: true,
formatter: function(value, row) {
return formatCurrency(value, {
currency: row.order_currency,
currency: row.order_currency ?? row.supplier_detail?.currency,
});
},
},

@@ -300,7 +300,7 @@ function loadReturnOrderTable(table, options={}) {
return '{% trans "Invalid Customer" %}';
}
return imageHoverIcon(row.customer_detail.image) + renderLink(row.customer_detail.name, `/company/${row.customer}/sales-orders/`);
return imageHoverIcon(row.customer_detail.image) + renderLink(row.customer_detail.name, `/company/${row.customer}/?display=sales-orders/`);
}
},
{
@@ -384,7 +384,7 @@ function loadReturnOrderTable(table, options={}) {
visible: false,
formatter: function(value, row) {
return formatCurrency(value, {
currency: row.order_currency
currency: row.order_currency ?? row.customer_detail?.currency,
});
}
}

@@ -788,7 +788,7 @@ function loadSalesOrderTable(table, options) {
return '{% trans "Invalid Customer" %}';
}
return imageHoverIcon(row.customer_detail.image) + renderLink(row.customer_detail.name, `/company/${row.customer}/sales-orders/`);
return imageHoverIcon(row.customer_detail.image) + renderLink(row.customer_detail.name, `/company/${row.customer}/?display=sales-orders/`);
}
},
{
@@ -857,7 +857,7 @@ function loadSalesOrderTable(table, options) {
sortable: true,
formatter: function(value, row) {
return formatCurrency(value, {
currency: row.order_currency,
currency: row.order_currency ?? row.customer_detail?.currency,
});
}
}

@@ -791,6 +791,10 @@ function getContactFilters() {
// Return a dictionary of filters for the "company" table
function getCompanyFilters() {
return {
active: {
type: 'bool',
title: '{% trans "Active" %}'
},
is_manufacturer: {
type: 'bool',
title: '{% trans "Manufacturer" %}',

@@ -8,9 +8,11 @@ from django.contrib.auth.models import Group, User
from django.urls import include, path, re_path
from django.views.generic.base import RedirectView
from dj_rest_auth.views import LogoutView
from dj_rest_auth.views import LoginView, LogoutView
from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view
from rest_framework import exceptions, permissions
from rest_framework.authentication import BasicAuthentication
from rest_framework.decorators import authentication_classes
from rest_framework.response import Response
from rest_framework.views import APIView
@@ -205,6 +207,18 @@ class GroupList(ListCreateAPI):
ordering_fields = ['name']
@authentication_classes([BasicAuthentication])
@extend_schema_view(
post=extend_schema(
responses={200: OpenApiResponse(description='User successfully logged in')}
)
)
class Login(LoginView):
"""API view for logging in via API."""
...
@extend_schema_view(
post=extend_schema(
responses={200: OpenApiResponse(description='User successfully logged out')}

@@ -56,6 +56,17 @@ def default_token_expiry():
return InvenTree.helpers.current_date() + datetime.timedelta(days=365)
def default_create_token(token_model, user, serializer):
"""Generate a default value for the token."""
token = token_model.objects.filter(user=user, name='', revoked=False)
if token.exists():
return token.first()
else:
return token_model.objects.create(user=user, name='')
class ApiToken(AuthToken, InvenTree.models.MetadataMixin):
"""Extends the default token model provided by djangorestframework.authtoken.

@@ -0,0 +1,38 @@
// eslint.config.js
import js from "@eslint/js";
import globals from "globals";
export default [
js.configs.recommended,
{
languageOptions: {
ecmaVersion: 12,
sourceType: "module",
globals: {
...globals.browser,
...globals.es2021,
...globals.jquery,
}
},
rules: {
"no-var": "off",
"guard-for-in": "off",
"no-trailing-spaces": "off",
"camelcase": "off",
"padded-blocks": "off",
"prefer-const": "off",
"max-len": "off",
"require-jsdoc": "off",
"valid-jsdoc": "off",
"no-multiple-empty-lines": "off",
"comma-dangle": "off",
"no-unused-vars": "off",
"no-useless-escape": "off",
"prefer-spread": "off",
"indent": ["error", 4]
},
linterOptions: {
reportUnusedDisableDirectives: "off"
}
}
];

@@ -5,7 +5,7 @@
"packages": {
"": {
"dependencies": {
"eslint": "^8.57.0",
"eslint": "^9.0.0",
"eslint-config-google": "^0.14.0"
}
},
@@ -31,6 +31,17 @@
"eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
}
},
"node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
"integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@eslint-community/regexpp": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz",
@@ -40,14 +51,14 @@
}
},
"node_modules/@eslint/eslintrc": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
"integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.0.2.tgz",
"integrity": "sha512-wV19ZEGEMAC1eHgrS7UQPqsdEiCIbTKTasEfcXAigzoXICcqZSjBZEHlZwNVvKg6UBCjSlos84XiLqsRJnIcIg==",
"dependencies": {
"ajv": "^6.12.4",
"debug": "^4.3.2",
"espree": "^9.6.0",
"globals": "^13.19.0",
"espree": "^10.0.1",
"globals": "^14.0.0",
"ignore": "^5.2.0",
"import-fresh": "^3.2.1",
"js-yaml": "^4.1.0",
@@ -55,26 +66,26 @@
"strip-json-comments": "^3.1.1"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@eslint/js": {
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz",
"integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==",
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.0.0.tgz",
"integrity": "sha512-RThY/MnKrhubF6+s1JflwUjPEsnCEmYCWwqa/aRISKWNXGZ9epUwft4bUMM35SdKF9xvBrLydAM1RDHd1Z//ZQ==",
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.11.14",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
"integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==",
"version": "0.12.3",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.12.3.tgz",
"integrity": "sha512-jsNnTBlMWuTpDkeE3on7+dWJi0D6fdDfeANj/w7MpS8ztROCoLvIO2nG0CcFj+E4k8j4QrSTh4Oryi3i2G669g==",
"dependencies": {
"@humanwhocodes/object-schema": "^2.0.2",
"@humanwhocodes/object-schema": "^2.0.3",
"debug": "^4.3.1",
"minimatch": "^3.0.5"
},
@@ -131,11 +142,6 @@
"node": ">= 8"
}
},
"node_modules/@ungap/structured-clone": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
"integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ=="
},
"node_modules/acorn": {
"version": "8.11.3",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz",
@@ -289,17 +295,6 @@
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="
},
"node_modules/doctrine": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
"integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
"dependencies": {
"esutils": "^2.0.2"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -312,40 +307,36 @@
}
},
"node_modules/eslint": {
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz",
"integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==",
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.0.0.tgz",
"integrity": "sha512-IMryZ5SudxzQvuod6rUdIUz29qFItWx281VhtFVc2Psy/ZhlCeD/5DT6lBIJ4H3G+iamGJoTln1v+QSuPw0p7Q==",
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
"@eslint/eslintrc": "^2.1.4",
"@eslint/js": "8.57.0",
"@humanwhocodes/config-array": "^0.11.14",
"@eslint/eslintrc": "^3.0.2",
"@eslint/js": "9.0.0",
"@humanwhocodes/config-array": "^0.12.3",
"@humanwhocodes/module-importer": "^1.0.1",
"@nodelib/fs.walk": "^1.2.8",
"@ungap/structured-clone": "^1.2.0",
"ajv": "^6.12.4",
"chalk": "^4.0.0",
"cross-spawn": "^7.0.2",
"debug": "^4.3.2",
"doctrine": "^3.0.0",
"escape-string-regexp": "^4.0.0",
"eslint-scope": "^7.2.2",
"eslint-visitor-keys": "^3.4.3",
"espree": "^9.6.1",
"eslint-scope": "^8.0.1",
"eslint-visitor-keys": "^4.0.0",
"espree": "^10.0.1",
"esquery": "^1.4.2",
"esutils": "^2.0.2",
"fast-deep-equal": "^3.1.3",
"file-entry-cache": "^6.0.1",
"file-entry-cache": "^8.0.0",
"find-up": "^5.0.0",
"glob-parent": "^6.0.2",
"globals": "^13.19.0",
"graphemer": "^1.4.0",
"ignore": "^5.2.0",
"imurmurhash": "^0.1.4",
"is-glob": "^4.0.0",
"is-path-inside": "^3.0.3",
"js-yaml": "^4.1.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
"levn": "^0.4.1",
"lodash.merge": "^4.6.2",
@@ -359,7 +350,7 @@
"eslint": "bin/eslint.js"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
@@ -377,42 +368,42 @@
}
},
"node_modules/eslint-scope": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
"integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.0.1.tgz",
"integrity": "sha512-pL8XjgP4ZOmmwfFE8mEhSxA7ZY4C+LWyqjQ3o4yWkkmD0qcMT9kkW3zWHOczhWcjTSgqycYAgwSlXvZltv65og==",
"dependencies": {
"esrecurse": "^4.3.0",
"estraverse": "^5.2.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint-visitor-keys": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
"integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz",
"integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==",
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/espree": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
"integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.0.1.tgz",
"integrity": "sha512-MWkrWZbJsL2UwnjxTX3gG8FneachS/Mwg7tdGXce011sJd5b0JG54vat5KHnfSBODZ3Wvzd2WnjxyzsRoVv+ww==",
"dependencies": {
"acorn": "^8.9.0",
"acorn": "^8.11.3",
"acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^3.4.1"
"eslint-visitor-keys": "^4.0.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
@@ -480,14 +471,14 @@
}
},
"node_modules/file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
"integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
"integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
"dependencies": {
"flat-cache": "^3.0.4"
"flat-cache": "^4.0.0"
},
"engines": {
"node": "^10.12.0 || >=12.0.0"
"node": ">=16.0.0"
}
},
"node_modules/find-up": {
@@ -506,16 +497,15 @@
}
},
"node_modules/flat-cache": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
"integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==",
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
"integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
"dependencies": {
"flatted": "^3.2.9",
"keyv": "^4.5.3",
"rimraf": "^3.0.2"
"keyv": "^4.5.4"
},
"engines": {
"node": "^10.12.0 || >=12.0.0"
"node": ">=16"
}
},
"node_modules/flatted": {
@@ -523,30 +513,6 @@
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz",
"integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw=="
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -559,14 +525,11 @@
}
},
"node_modules/globals": {
"version": "13.24.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
"integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
"dependencies": {
"type-fest": "^0.20.2"
},
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
"engines": {
"node": ">=8"
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
@@ -616,20 +579,6 @@
"node": ">=0.8.19"
}
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -748,14 +697,6 @@
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/optionator": {
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz",
@@ -819,14 +760,6 @@
"node": ">=8"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -887,20 +820,6 @@
"node": ">=0.10.0"
}
},
"node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -991,17 +910,6 @@
"node": ">= 0.8.0"
}
},
"node_modules/type-fest": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -1024,11 +932,6 @@
"node": ">= 8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

@@ -1,6 +1,7 @@
{
"dependencies": {
"eslint": "^8.57.0",
"eslint": "^9.0.0",
"eslint-config-google": "^0.14.0"
}
},
"type": "module"
}

@@ -63,7 +63,7 @@ pyyaml==6.0.1
# via pre-commit
requests==2.31.0
# via coveralls
setuptools==69.2.0
setuptools==69.5.1
# via
# nodeenv
# pip-tools

@@ -130,7 +130,7 @@ googleapis-common-protos==1.63.0
# opentelemetry-exporter-otlp-proto-http
grpcio==1.62.1
# via opentelemetry-exporter-otlp-proto-grpc
gunicorn==21.2.0
gunicorn==22.0.0
html5lib==1.1
# via weasyprint
icalendar==5.0.12
@@ -288,9 +288,9 @@ rpds-py==0.18.0
# via
# jsonschema
# referencing
sentry-sdk==1.44.1
sentry-sdk==1.45.0
# via django-q-sentry
setuptools==69.2.0
setuptools==69.5.1
# via
# django-money
# opentelemetry-instrumentation

@@ -18,7 +18,7 @@
"@fortawesome/free-solid-svg-icons": "^6.5.2",
"@fortawesome/react-fontawesome": "^0.2.0",
"@lingui/core": "^4.7.1",
"@lingui/react": "^4.7.2",
"@lingui/react": "^4.10.0",
"@mantine/carousel": "<7",
"@mantine/core": "<7",
"@mantine/dates": "<7",
@@ -27,6 +27,7 @@
"@mantine/hooks": "<7",
"@mantine/modals": "<7",
"@mantine/notifications": "<7",
"@mantine/spotlight": "<7",
"@naisutech/react-tree": "^3.1.0",
"@sentry/react": "^7.109.0",
"@tabler/icons-react": "^3.1.0",
@@ -37,17 +38,18 @@
"axios": "^1.6.7",
"dayjs": "^1.11.10",
"easymde": "^2.18.0",
"embla-carousel-react": "^8.0.0",
"embla-carousel-react": "^8.0.2",
"html5-qrcode": "^2.3.8",
"mantine-datatable": "<7",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-grid-layout": "^1.4.4",
"react-hook-form": "^7.51.2",
"react-hook-form": "^7.51.3",
"react-is": "^18.2.0",
"react-router-dom": "^6.22.1",
"react-select": "^5.8.0",
"react-simplemde-editor": "^5.2.0",
"recharts": "^2.12.4",
"styled-components": "^5.3.6",
"zustand": "^4.5.1"
},
@@ -56,8 +58,8 @@
"@babel/preset-react": "^7.23.3",
"@babel/preset-typescript": "^7.23.3",
"@lingui/cli": "^4.7.2",
"@lingui/macro": "^4.7.2",
"@playwright/test": "^1.41.2",
"@lingui/macro": "^4.10.0",
"@playwright/test": "^1.43.1",
"@types/node": "^20.12.3",
"@types/react": "^18.2.74",
"@types/react-dom": "^18.2.23",

@@ -1,40 +1,24 @@
import { QueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { getCsrfCookie } from './functions/auth';
import { useLocalState } from './states/LocalState';
import { useSessionState } from './states/SessionState';
// Global API instance
export const api = axios.create({});
/*
* Setup default settings for the Axios API instance.
*
* This includes:
* - Base URL
* - Authorization token (if available)
* - CSRF token (if available)
*/
export function setApiDefaults() {
const host = useLocalState.getState().host;
const token = useSessionState.getState().token;
api.defaults.baseURL = host;
api.defaults.timeout = 2500;
api.defaults.headers.common['Authorization'] = token
? `Token ${token}`
: undefined;
if (!!getCsrfCookie()) {
api.defaults.withCredentials = true;
api.defaults.withXSRFToken = true;
api.defaults.xsrfCookieName = 'csrftoken';
api.defaults.xsrfHeaderName = 'X-CSRFToken';
} else {
api.defaults.withCredentials = false;
api.defaults.xsrfCookieName = undefined;
api.defaults.xsrfHeaderName = undefined;
}
}
export const queryClient = new QueryClient();

@@ -0,0 +1,15 @@
import { t } from '@lingui/macro';
import { ActionIcon } from '@mantine/core';
import { spotlight } from '@mantine/spotlight';
import { IconCommand } from '@tabler/icons-react';
/**
* A button which opens the quick command modal
*/
export function SpotlightButton() {
return (
<ActionIcon onClick={() => spotlight.open()} title={t`Open spotlight`}>
<IconCommand />
</ActionIcon>
);
}

@@ -0,0 +1,12 @@
export const CHART_COLORS: string[] = [
'#ffa8a8',
'#8ce99a',
'#74c0fc',
'#ffe066',
'#63e6be',
'#ffc078',
'#d8f5a2',
'#66d9e8',
'#e599f7',
'#dee2e6'
];

@@ -0,0 +1,9 @@
import { formatCurrency } from '../../defaults/formatters';
export function tooltipFormatter(label: any, currency: string) {
return (
formatCurrency(label, {
currency: currency
})?.toString() ?? ''
);
}

@@ -0,0 +1,26 @@
import { Badge } from '@mantine/core';
export type DetailsBadgeProps = {
color: string;
label: string;
size?: string;
visible?: boolean;
key?: any;
};
export default function DetailsBadge(props: DetailsBadgeProps) {
if (props.visible == false) {
return null;
}
return (
<Badge
key={props.key}
color={props.color}
variant="filled"
size={props.size ?? 'lg'}
>
{props.label}
</Badge>
);
}

@@ -66,6 +66,7 @@ export interface ApiFormProps {
pathParams?: PathParams;
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
fields?: ApiFormFieldSet;
focus?: string;
initialData?: FieldValues;
submitText?: string;
submitColor?: string;
@@ -106,6 +107,8 @@ export function OptionsApiForm({
const optionsQuery = useQuery({
enabled: true,
refetchOnMount: false,
refetchOnWindowFocus: false,
queryKey: [
'form-options-data',
id,
@@ -180,20 +183,25 @@ export function ApiForm({
props: ApiFormProps;
optionsLoading: boolean;
}) {
const fields: ApiFormFieldSet = useMemo(() => {
return props.fields ?? {};
}, [props.fields]);
const defaultValues: FieldValues = useMemo(() => {
let defaultValuesMap = mapFields(props.fields ?? {}, (_path, field) => {
let defaultValuesMap = mapFields(fields ?? {}, (_path, field) => {
return field.value ?? field.default ?? undefined;
});
// If the user has specified initial data, use that instead
// If the user has specified initial data, that overrides default values
// But, *only* for the fields we have specified
if (props.initialData) {
defaultValuesMap = {
...defaultValuesMap,
...props.initialData
};
Object.keys(props.initialData).map((key) => {
if (key in defaultValuesMap) {
defaultValuesMap[key] =
props?.initialData?.[key] ?? defaultValuesMap[key];
}
});
}
// Update the form values, but only for the fields specified for this form
return defaultValuesMap;
}, [props.fields, props.initialData]);
@@ -259,14 +267,22 @@ export function ApiForm({
};
// Process API response
const initialData: any = processFields(
props.fields ?? {},
response.data
);
const initialData: any = processFields(fields, response.data);
// Update form values, but only for the fields specified for this form
form.reset(initialData);
// Update the field references, too
Object.keys(fields).forEach((fieldName) => {
if (fieldName in initialData) {
let field = fields[fieldName] ?? {};
fields[fieldName] = {
...field,
value: initialData[fieldName]
};
}
});
return response;
} catch (error) {
console.error('Error fetching initial data:', error);
@@ -292,7 +308,48 @@ export function ApiForm({
});
initialDataQuery.refetch();
}
}, []);
}, [props.fetchInitialData]);
const isLoading = useMemo(
() =>
isFormLoading ||
initialDataQuery.isFetching ||
optionsLoading ||
isSubmitting ||
!fields,
[
isFormLoading,
initialDataQuery.isFetching,
isSubmitting,
fields,
optionsLoading
]
);
const [initialFocus, setInitialFocus] = useState<string>('');
// Update field focus when the form is loaded
useEffect(() => {
let focusField = props.focus ?? '';
if (!focusField) {
// If a focus field is not specified, then focus on the first available field
Object.entries(fields).forEach(([fieldName, field]) => {
if (focusField || field.read_only || field.disabled || field.hidden) {
return;
}
focusField = fieldName;
});
}
if (isLoading || initialFocus == focusField) {
return;
}
form.setFocus(focusField);
setInitialFocus(focusField);
}, [props.focus, fields, form.setFocus, isLoading, initialFocus]);
const submitForm: SubmitHandler<FieldValues> = async (data) => {
setNonFieldErrors([]);
@@ -300,7 +357,7 @@ export function ApiForm({
let method = props.method?.toLowerCase() ?? 'get';
let hasFiles = false;
mapFields(props.fields ?? {}, (_path, field) => {
mapFields(fields, (_path, field) => {
if (field.field_type === 'file upload') {
hasFiles = true;
}
@@ -392,22 +449,6 @@ export function ApiForm({
});
};
const isLoading = useMemo(
() =>
isFormLoading ||
initialDataQuery.isFetching ||
optionsLoading ||
isSubmitting ||
!props.fields,
[
isFormLoading,
initialDataQuery.isFetching,
isSubmitting,
props.fields,
optionsLoading
]
);
const onFormError = useCallback<SubmitErrorHandler<FieldValues>>(() => {
props.onFormError?.();
}, [props.onFormError]);
@@ -448,16 +489,14 @@ export function ApiForm({
<FormProvider {...form}>
<Stack spacing="xs">
{!optionsLoading &&
Object.entries(props.fields ?? {}).map(
([fieldName, field]) => (
Object.entries(fields).map(([fieldName, field]) => (
<ApiFormField
key={fieldName}
fieldName={fieldName}
definition={field}
control={form.control}
/>
)
)}
))}
</Stack>
</FormProvider>
{props.postFormContent}

@@ -12,16 +12,14 @@ import {
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconCheck } from '@tabler/icons-react';
import { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../../App';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { doBasicLogin, doSimpleLogin } from '../../functions/auth';
import { doBasicLogin, doSimpleLogin, isLoggedIn } from '../../functions/auth';
import { showLoginNotification } from '../../functions/notifications';
import { apiUrl, useServerApiState } from '../../states/ApiState';
import { useSessionState } from '../../states/SessionState';
import { SsoButton } from '../buttons/SSOButton';
export function AuthenticationForm() {
@@ -46,19 +44,18 @@ export function AuthenticationForm() {
).then(() => {
setIsLoggingIn(false);
if (useSessionState.getState().hasToken()) {
notifications.show({
if (isLoggedIn()) {
showLoginNotification({
title: t`Login successful`,
message: t`Welcome back!`,
color: 'green',
icon: <IconCheck size="1rem" />
message: t`Logged in successfully`
});
navigate(location?.state?.redirectFrom ?? '/home');
} else {
notifications.show({
showLoginNotification({
title: t`Login failed`,
message: t`Check your input and try again.`,
color: 'red'
success: false
});
}
});
@@ -67,18 +64,15 @@ export function AuthenticationForm() {
setIsLoggingIn(false);
if (ret?.status === 'ok') {
notifications.show({
showLoginNotification({
title: t`Mail delivery successful`,
message: t`Check your inbox for the login link. If you have an account, you will receive a login link. Check in spam too.`,
color: 'green',
icon: <IconCheck size="1rem" />,
autoClose: false
message: t`Check your inbox for the login link. If you have an account, you will receive a login link. Check in spam too.`
});
} else {
notifications.show({
title: t`Input error`,
showLoginNotification({
title: t`Mail delivery failed`,
message: t`Check your input and try again.`,
color: 'red'
success: false
});
}
});
@@ -193,11 +187,9 @@ export function RegistrationForm() {
.then((ret) => {
if (ret?.status === 204) {
setIsRegistering(false);
notifications.show({
showLoginNotification({
title: t`Registration successful`,
message: t`Please confirm your email address to complete the registration`,
color: 'green',
icon: <IconCheck size="1rem" />
message: t`Please confirm your email address to complete the registration`
});
navigate('/home');
}
@@ -212,11 +204,10 @@ export function RegistrationForm() {
if (err.response?.data?.non_field_errors) {
err_msg = err.response.data.non_field_errors;
}
notifications.show({
showLoginNotification({
title: t`Input error`,
message: t`Check your input and try again. ` + err_msg,
color: 'red',
autoClose: 30000
success: false
});
}
});

@@ -15,6 +15,7 @@ import { useMemo } from 'react';
import { Control, FieldValues, useController } from 'react-hook-form';
import { ModelType } from '../../../enums/ModelType';
import { isTrue } from '../../../functions/conversion';
import { ChoiceField } from './ChoiceField';
import DateField from './DateField';
import { NestedObjectField } from './NestedObjectField';
@@ -188,7 +189,7 @@ export function ApiFormField({
return (
<TextInput
{...reducedDefinition}
ref={ref}
ref={field.ref}
id={fieldId}
type={definition.field_type}
value={value || ''}
@@ -210,7 +211,7 @@ export function ApiFormField({
id={fieldId}
radius="lg"
size="sm"
checked={value ?? false}
checked={isTrue(value)}
error={error?.message}
onChange={(event) => onChange(event.currentTarget.checked)}
/>
@@ -226,21 +227,14 @@ export function ApiFormField({
<NumberInput
{...reducedDefinition}
radius="sm"
ref={ref}
ref={field.ref}
id={fieldId}
value={numericalValue}
error={error?.message}
formatter={(value) => {
let v: any = parseFloat(value);
if (Number.isNaN(v) || !Number.isFinite(v)) {
return value;
}
return `${1 * v.toFixed()}`;
}}
precision={definition.field_type == 'integer' ? 0 : 10}
onChange={(value: number) => onChange(value)}
removeTrailingZeros
step={1}
/>
);
case 'choice':
@@ -256,7 +250,7 @@ export function ApiFormField({
<FileInput
{...reducedDefinition}
id={fieldId}
ref={ref}
ref={field.ref}
radius="sm"
value={value}
error={error?.message}

@@ -52,6 +52,7 @@ export default function DateField({
<DateInput
id={fieldId}
radius="sm"
ref={field.ref}
type={undefined}
error={error?.message}
value={dateValue}

@@ -269,6 +269,7 @@ export function RelatedModelField({
<Select
id={fieldId}
value={currentValue}
ref={field.ref}
options={data}
filterOption={null}
onInputChange={(value: any) => {

@@ -1,71 +1,27 @@
/**
* Component for loading an image from the InvenTree server,
* using the API's token authentication.
* Component for loading an image from the InvenTree server
*
* Image caching is handled automagically by the browsers cache
*/
import { Image, ImageProps, Skeleton, Stack } from '@mantine/core';
import { useId } from '@mantine/hooks';
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { useMemo } from 'react';
import { api } from '../../App';
import { useLocalState } from '../../states/LocalState';
/**
* Construct an image container which will load and display the image
*/
export function ApiImage(props: ImageProps) {
const [image, setImage] = useState<string>('');
const { host } = useLocalState.getState();
const [authorized, setAuthorized] = useState<boolean>(true);
const queryKey = useId();
const _imgQuery = useQuery({
queryKey: ['image', queryKey, props.src],
enabled:
authorized &&
props.src != undefined &&
props.src != null &&
props.src != '',
queryFn: async () => {
if (!props.src) {
return null;
}
return api
.get(props.src, {
responseType: 'blob'
})
.then((response) => {
switch (response.status) {
case 200:
let img = new Blob([response.data], {
type: response.headers['content-type']
});
let url = URL.createObjectURL(img);
setImage(url);
break;
default:
// User is not authorized to view this image, or the image is not available
setImage('');
setAuthorized(false);
break;
}
return response;
})
.catch((_error) => {
return null;
});
},
refetchOnMount: true,
refetchOnWindowFocus: false
});
const imageUrl = useMemo(() => {
return `${host}${props.src}`;
}, [host, props.src]);
return (
<Stack>
{image && image.length > 0 ? (
<Image {...props} src={image} withPlaceholder fit="contain" />
{imageUrl ? (
<Image {...props} src={imageUrl} withPlaceholder fit="contain" />
) : (
<Skeleton
height={props?.height ?? props.width}

@@ -13,6 +13,7 @@ export function Thumbnail({
src,
alt = t`Thumbnail`,
size = 20,
link,
text,
align
}: {
@@ -21,9 +22,22 @@ export function Thumbnail({
size?: number;
text?: ReactNode;
align?: string;
link?: string;
}) {
const backup_image = '/static/img/blank_image.png';
const inner = useMemo(() => {
if (link) {
return (
<Anchor href={link} target="_blank">
{text}
</Anchor>
);
} else {
return text;
}
}, [link, text]);
return (
<Group align={align ?? 'left'} spacing="xs" noWrap={true}>
<ApiImage
@@ -39,7 +53,7 @@ export function Thumbnail({
}
}}
/>
{text}
{inner}
</Group>
);
}

@@ -8,7 +8,9 @@ import {
IconFileTypeXls,
IconFileTypeZip
} from '@tabler/icons-react';
import { ReactNode } from 'react';
import { ReactNode, useMemo } from 'react';
import { useLocalState } from '../../states/LocalState';
/**
* Return an icon based on the provided filename
@@ -58,10 +60,20 @@ export function AttachmentLink({
}): ReactNode {
let text = external ? attachment : attachment.split('/').pop();
const host = useLocalState((s) => s.host);
const url = useMemo(() => {
if (external) {
return attachment;
}
return `${host}${attachment}`;
}, [host, attachment, external]);
return (
<Group position="left" spacing="sm">
{external ? <IconLink /> : attachmentIcon(attachment)}
<Anchor href={attachment} target="_blank" rel="noopener noreferrer">
<Anchor href={url} target="_blank" rel="noopener noreferrer">
{text}
</Anchor>
</Group>

@@ -16,6 +16,10 @@ export interface MenuLinkItem {
docchildren?: React.ReactNode;
}
export type menuItemsCollection = {
[key: string]: MenuLinkItem;
};
function ConditionalDocTooltip({
item,
children

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