mirror of
https://github.com/inventree/InvenTree.git
synced 2026-07-05 22:52:33 +00:00
Merge branch 'master' into custom-states
This commit is contained in:
@@ -31,7 +31,8 @@
|
||||
"ms-python.python",
|
||||
"ms-python.vscode-pylance",
|
||||
"batisteo.vscode-django",
|
||||
"eamodio.gitlens"
|
||||
"eamodio.gitlens",
|
||||
"biomejs.biome"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -308,7 +308,7 @@ jobs:
|
||||
- name: Coverage Tests
|
||||
run: invoke dev.test --coverage
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # pin@v4.6.0
|
||||
uses: codecov/codecov-action@5c47607acb93fed5485fdbf7232e8a31425f672a # pin@v5.0.2
|
||||
if: always()
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
@@ -440,7 +440,7 @@ jobs:
|
||||
- name: Run Tests
|
||||
run: invoke dev.test --migrations --report --coverage
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # pin@v4.6.0
|
||||
uses: codecov/codecov-action@5c47607acb93fed5485fdbf7232e8a31425f672a # pin@v5.0.2
|
||||
if: always()
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
@@ -545,7 +545,7 @@ jobs:
|
||||
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 reports to Codecov
|
||||
uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # pin@v4.6.0
|
||||
uses: codecov/codecov-action@5c47607acb93fed5485fdbf7232e8a31425f672a # pin@v5.0.2
|
||||
if: always()
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
@@ -584,3 +584,4 @@ jobs:
|
||||
with:
|
||||
name: frontend-build
|
||||
path: src/backend/InvenTree/web/static/web
|
||||
include-hidden-files: true
|
||||
|
||||
@@ -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@251a468eed47e5082b105c3ba6ee500c0e65a764 # pin@v0
|
||||
uses: anchore/sbom-action@fc46e51fd3cb168ffb36c6d1915723c47db58abb # 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@1c608d11d69870c2092266b3f9a6f3abbf17002c # pin@v1
|
||||
uses: actions/attest-build-provenance@ef244123eb79f2f7a7e75d99086184180e6d0018 # pin@v1
|
||||
with:
|
||||
subject-path: "${{ github.workspace }}/src/backend/InvenTree/web/static/frontend-build.zip"
|
||||
|
||||
|
||||
@@ -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@662472033e021d55d94146f66f6058822b0b39fd # v3.27.0
|
||||
uses: github/codeql-action/upload-sarif@ea9e4e37992a54ee68a9622e985e60c8e8f12d9f # v3.27.4
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -88,6 +88,10 @@ env/
|
||||
src/backend/InvenTree/InvenTree/locale_stats.json
|
||||
src/backend/InvenTree/InvenTree/licenses.txt
|
||||
|
||||
# Logs
|
||||
src/backend/InvenTree/logs.json
|
||||
src/backend/InvenTree/logs.log
|
||||
|
||||
# node.js
|
||||
node_modules/
|
||||
|
||||
|
||||
+11
-25
@@ -10,14 +10,14 @@ exclude: |
|
||||
)$
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.6.0
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: mixed-line-ending
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.7.0
|
||||
rev: v0.7.3
|
||||
hooks:
|
||||
- id: ruff-format
|
||||
args: [--preview]
|
||||
@@ -28,7 +28,7 @@ repos:
|
||||
--preview
|
||||
]
|
||||
- repo: https://github.com/astral-sh/uv-pre-commit
|
||||
rev: 0.4.24
|
||||
rev: 0.5.1
|
||||
hooks:
|
||||
- id: pip-compile
|
||||
name: pip-compile requirements-dev.in
|
||||
@@ -51,7 +51,7 @@ repos:
|
||||
args: [contrib/container/requirements.in, -o, contrib/container/requirements.txt, --python-version=3.11, --no-strip-extras, --generate-hashes]
|
||||
files: contrib/container/requirements\.(in|txt)$
|
||||
- repo: https://github.com/Riverside-Healthcare/djLint
|
||||
rev: v1.35.2
|
||||
rev: v1.36.1
|
||||
hooks:
|
||||
- id: djlint-django
|
||||
- repo: https://github.com/codespell-project/codespell
|
||||
@@ -69,28 +69,14 @@ repos:
|
||||
pyproject.toml |
|
||||
src/frontend/vite.config.ts |
|
||||
)$
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: "v4.0.0-alpha.8"
|
||||
hooks:
|
||||
- id: prettier
|
||||
files: ^src/frontend/.*\.(js|jsx|ts|tsx)$
|
||||
additional_dependencies:
|
||||
- "prettier@^2.4.1"
|
||||
- "@trivago/prettier-plugin-sort-imports"
|
||||
- repo: https://github.com/pre-commit/mirrors-eslint
|
||||
rev: "v9.12.0"
|
||||
hooks:
|
||||
- id: eslint
|
||||
additional_dependencies:
|
||||
- eslint@^8.41.0
|
||||
- eslint-config-google@^0.14.0
|
||||
- eslint-plugin-react@6.10.3
|
||||
- babel-eslint@6.1.2
|
||||
- "@typescript-eslint/eslint-plugin@latest"
|
||||
- "@typescript-eslint/parser"
|
||||
files: ^src/frontend/.*\.(js|jsx|ts|tsx)$
|
||||
- repo: https://github.com/biomejs/pre-commit
|
||||
rev: "v0.5.0"
|
||||
hooks:
|
||||
- id: biome-check
|
||||
additional_dependencies: ["@biomejs/biome@1.9.4"]
|
||||
files: ^src/frontend/.*\.(js|ts|tsx)$
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
rev: v8.21.0
|
||||
rev: v8.21.2
|
||||
hooks:
|
||||
- id: gitleaks
|
||||
#- repo: https://github.com/jumanjihouse/pre-commit-hooks
|
||||
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"biomejs.biome"
|
||||
]
|
||||
}
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"quickfix.biome": "explicit"
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "single",
|
||||
"jsxQuoteStyle": "single",
|
||||
"trailingCommas": "none",
|
||||
"indentStyle": "space"
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"rules": {
|
||||
"suspicious" : {
|
||||
"noExplicitAny": "off",
|
||||
"noDoubleEquals": "off",
|
||||
"noArrayIndexKey": "off",
|
||||
"useDefaultSwitchClauseLast": "off"
|
||||
},
|
||||
"style": {
|
||||
"noUselessElse": "off",
|
||||
"noNonNullAssertion": "off",
|
||||
"noParameterAssign": "off"
|
||||
}, "correctness":{
|
||||
"useExhaustiveDependencies": "off",
|
||||
"useJsxKeyInIterable": "off",
|
||||
"noUnsafeOptionalChaining": "off",
|
||||
"noSwitchDeclarations": "off",
|
||||
"noUnusedImports":"error"
|
||||
}, "complexity": {
|
||||
"noBannedTypes": "off",
|
||||
"noExtraBooleanCast": "off",
|
||||
"noForEach": "off",
|
||||
"noUselessSwitchCase": "off",
|
||||
"useLiteralKeys":"off"
|
||||
}, "performance": {
|
||||
"noDelete":"off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,13 +24,11 @@ INVENTREE_DB_PORT=5432
|
||||
INVENTREE_DB_USER=pguser
|
||||
INVENTREE_DB_PASSWORD=pgpassword
|
||||
|
||||
# Redis cache setup (disabled by default)
|
||||
# Un-comment the following lines to enable Redis cache
|
||||
# Note that you will also have to run docker-compose with the --profile redis command
|
||||
# Redis cache setup
|
||||
# Refer to settings.py for other cache options
|
||||
#INVENTREE_CACHE_ENABLED=True
|
||||
#INVENTREE_CACHE_HOST=inventree-cache
|
||||
#INVENTREE_CACHE_PORT=6379
|
||||
INVENTREE_CACHE_ENABLED=True
|
||||
INVENTREE_CACHE_HOST=inventree-cache
|
||||
INVENTREE_CACHE_PORT=6379
|
||||
|
||||
# Options for gunicorn server
|
||||
INVENTREE_GUNICORN_TIMEOUT=90
|
||||
|
||||
@@ -71,6 +71,7 @@ services:
|
||||
- 8000
|
||||
depends_on:
|
||||
- inventree-db
|
||||
- inventree-cache
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
|
||||
@@ -112,3 +112,25 @@ npx playwright test --ui
|
||||
```
|
||||
|
||||
This will first launch the backend server (at `http://localhost:8000`), and then run the tests against the frontend server (at `http://localhost:5173`). An interactive browser window will open, and you can run the tests individually or as a group.
|
||||
|
||||
### Viewing Reports
|
||||
|
||||
The playwright tests are run automatically as part of the project's CI pipeline, and the results are stored as a downloadable report. The report file can be "replayed" using playwright, to view the results of the test run, as well as closely inspect any failed tests.
|
||||
|
||||
To view the report, you can use the following command, after downloading the report and extracting from the zipped file:
|
||||
|
||||
```bash
|
||||
npx playwright show-report path/to/report
|
||||
```
|
||||
|
||||
### No Tests Found
|
||||
|
||||
If there is any problem in the testing launch sequence, the playwright UI will display the message "No Tests". In this case, an error has occurred, likely launching the InvenTree server process (which runs in the background).
|
||||
|
||||
To debug this situation, and determine what error needs to be resolved, run the following command:
|
||||
|
||||
```bash
|
||||
npx playwright test --debug
|
||||
```
|
||||
|
||||
This will print out any errors to the console, allowing you to resolve issues before continuing. In all likelihood, your InvenTree installation needs to be updated, and simply running `invoke update` will allow you to continue.
|
||||
|
||||
@@ -7,7 +7,7 @@ title: Machines
|
||||
InvenTree has a builtin machine registry. There are different machine types available where each type can have different drivers. Drivers and even custom machine types can be provided by plugins.
|
||||
|
||||
!!! info "Requires Redis"
|
||||
If the machines features is used in production setup using workers, a shared [redis cache](../../start/docker.md#redis-cache) is required to function properly.
|
||||
If the machines features is used in production setup using workers, a shared [redis cache](../../start/processes.md#cache-server) is required to function properly.
|
||||
|
||||
### Registry
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ The configuration entries must be enabled via the [InvenTree admin interface](..
|
||||
!!! warning "Disabled by Default"
|
||||
Newly discovered plugins are disabled by default, and must be manually enabled (in the admin interface) by a user with staff privileges.
|
||||
|
||||
### Plugin Mixins
|
||||
## Plugin Mixins
|
||||
|
||||
Common use cases are covered by pre-supplied modules in the form of *mixins* (similar to how [Django]({% include "django.html" %}/topics/class-based-views/mixins/) does it). Each mixin enables the integration into a specific area of InvenTree. Sometimes it also enhances the plugin with helper functions to supply often used functions out-of-the-box.
|
||||
|
||||
@@ -106,3 +106,11 @@ Supported mixin classes are:
|
||||
| [SettingsMixin](./plugins/settings.md) | Integrate user configurable settings |
|
||||
| [UrlsMixin](./plugins/urls.md) | Respond to custom URL endpoints |
|
||||
| [ValidationMixin](./plugins/validation.md) | Provide custom validation of database models |
|
||||
|
||||
## Static Files
|
||||
|
||||
If your plugin requires static files (e.g. CSS, JavaScript, images), these should be placed in the top level `static` directory within the distributed plugin package. These files will be automatically collected by InvenTree when the plugin is installed, and copied to an appropriate location.
|
||||
|
||||
These files will be available to the InvenTree web interface, and can be accessed via the URL `/static/plugins/<plugin_name>/<filename>`. Static files are served by the [proxy server](../start/processes.md#proxy-server).
|
||||
|
||||
For example, if the plugin is named `my_plugin`, and contains a file `CustomPanel.js`, it can be accessed via the URL `/static/plugins/my_plugin/CustomPanel.js`.
|
||||
|
||||
@@ -173,9 +173,17 @@ This context data can be used to provide additional information to the rendering
|
||||
|
||||
Note that additional context can be passed to the rendering functions by adding additional key-value pairs to the `context` field in the `UIFeature` return type (provided by the backend via the API). This field is optional, and can be used at the discretion of the plugin developer.
|
||||
|
||||
## File Distribution
|
||||
|
||||
When distributing a custom UI plugin, the plugin should include the necessary frontend code to render the custom content. This frontend code should be included in the plugin package, and should be made available to the InvenTree frontend when the plugin is installed.
|
||||
|
||||
The simplest (and recommended) way to achieve this is to distribute the compiled javascript files with the plugin package, in a top-level `static` directory. This directory will be automatically collected by InvenTree when the plugin is installed, and the files will be copied to the appropriate location.
|
||||
|
||||
Read more about [static plugin files](../plugins.md#static-files) for more information.
|
||||
|
||||
## Sample Plugin
|
||||
|
||||
A sample plugin which implements custom user interface functionality is provided in the InvenTree source code, which provides a full working example of how to implement custom user interface functionality.
|
||||
A (very simple) sample plugin which implements custom user interface functionality is provided in the InvenTree source code, which provides a full working example of how to implement custom user interface functionality.
|
||||
|
||||
::: plugin.samples.integration.user_interface_sample.SampleUserInterfacePlugin
|
||||
options:
|
||||
@@ -184,3 +192,28 @@ A sample plugin which implements custom user interface functionality is provided
|
||||
show_root_toc_entry: False
|
||||
show_source: True
|
||||
members: []
|
||||
|
||||
### More Examples
|
||||
|
||||
Some more complex examples of user interface plugins can be found on the InvenTree GitHub repository:
|
||||
|
||||
- [inventree-test-statistics](https://github.com/inventree/inventree-test-statistics)
|
||||
- [inventree-order-history](https://github.com/inventree/inventree-order-history)
|
||||
|
||||
## Consistent Theming
|
||||
|
||||
When developing a custom UI plugin for InvenTree, the plugin should aim to match the existing InvenTree theme as closely as possible. This will help to ensure that the custom content fits seamlessly into the existing user interface.
|
||||
|
||||
To achieve this, we strongly recommend that you use the same framework as the InvenTree frontend - which is built using [React](https://react.dev) on top of the [Mantine](https://mantine.dev) UI component library.
|
||||
|
||||
### Mantine
|
||||
|
||||
The Mantine UI component library is used throughout the InvenTree frontend, and provides a consistent look and feel to the user interface. By using Mantine components in your custom UI plugin, you can ensure that your custom content fits seamlessly into the existing InvenTree theme.
|
||||
|
||||
### InvenTree Component Library
|
||||
|
||||
We are working to develop and distribute a library of custom InvenTree components which can be used to build custom UI plugins. This library will be made available to plugin developers in the near future.
|
||||
|
||||
### Examples
|
||||
|
||||
Refer to some of the existing InvenTree plugins linked above for examples of building custom UI plugins using the Mantine component library for seamless integration.
|
||||
|
||||
@@ -63,6 +63,10 @@ For more information, refer to the installation guides:
|
||||
!!! warning "Invoke Update"
|
||||
You must ensure that the `invoke update` command is performed *every time* you update InvenTree
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
Before performing an update, check the release notes! Any *breaking changes* (changes which require user intervention) will be clearly noted.
|
||||
|
||||
### Feature *x* does not work after update
|
||||
|
||||
If a particular menu / item is not visible after updating InvenTree, or a certain function no longer seems to work, it may be due to your internet browser caching old versions of CSS and JavaScript files.
|
||||
|
||||
+112
-23
@@ -41,6 +41,13 @@ A number of helper functions are available for accessing data contained in a par
|
||||
|
||||
To return the element at a given index in a container which supports indexed access (such as a [list](https://www.w3schools.com/python/python_lists.asp)), use the `getindex` function:
|
||||
|
||||
::: report.templatetags.report.getindex
|
||||
options:
|
||||
show_docstring_description: false
|
||||
show_source: False
|
||||
|
||||
#### Example
|
||||
|
||||
```html
|
||||
{% raw %}
|
||||
{% getindex my_list 1 as value %}
|
||||
@@ -53,6 +60,13 @@ Item: {{ value }}
|
||||
To return an element corresponding to a certain key in a container which supports key access (such as a [dictionary](https://www.w3schools.com/python/python_dictionaries.asp)), use the `getkey` function:
|
||||
|
||||
|
||||
::: report.templatetags.report.getkey
|
||||
options:
|
||||
show_docstring_description: false
|
||||
show_source: False
|
||||
|
||||
#### Example
|
||||
|
||||
```html
|
||||
{% raw %}
|
||||
<ul>
|
||||
@@ -66,8 +80,17 @@ To return an element corresponding to a certain key in a container which support
|
||||
|
||||
## Number Formatting
|
||||
|
||||
### format_number
|
||||
|
||||
The helper function `format_number` allows for some common number formatting options. It takes a number (or a number-like string) as an input, as well as some formatting arguments. It returns a *string* containing the formatted number:
|
||||
|
||||
::: report.templatetags.report.format_number
|
||||
options:
|
||||
show_docstring_description: false
|
||||
show_source: False
|
||||
|
||||
#### Example
|
||||
|
||||
```html
|
||||
{% raw %}
|
||||
{% load report %}
|
||||
@@ -82,15 +105,24 @@ The helper function `format_number` allows for some common number formatting opt
|
||||
|
||||
For rendering date and datetime information, the following helper functions are available:
|
||||
|
||||
- `format_date`: Format a date object
|
||||
- `format_datetime`: Format a datetime object
|
||||
### format_date
|
||||
|
||||
Each of these helper functions takes a date or datetime object as an input, and returns a *string* containing the formatted date or datetime. The following additional arguments are available:
|
||||
::: report.templatetags.report.format_date
|
||||
options:
|
||||
show_docstring_description: false
|
||||
show_source: False
|
||||
|
||||
### format_datetime
|
||||
|
||||
::: report.templatetags.report.format_datetime
|
||||
options:
|
||||
show_docstring_description: false
|
||||
show_source: False
|
||||
|
||||
### Date Formatting
|
||||
|
||||
If not specified, these methods return a result which uses ISO formatting. Refer to the [datetime format codes](https://docs.python.org/3/library/datetime.html#format-codes) for more information! |
|
||||
|
||||
| Argument | Description |
|
||||
| --- | --- |
|
||||
| timezone | Specify the timezone to render the date in. If not specified, uses the InvenTree server timezone |
|
||||
| format | Specify the format string to use for rendering the date. If not specified, uses ISO formatting. Refer to the [datetime format codes](https://docs.python.org/3/library/datetime.html#format-codes) for more information! |
|
||||
|
||||
### Example
|
||||
|
||||
@@ -106,8 +138,18 @@ Datetime: {% format_datetime my_datetime format="%d-%m-%Y %H:%M%S" %}
|
||||
|
||||
## Currency Formatting
|
||||
|
||||
### render_currency
|
||||
|
||||
The helper function `render_currency` allows for simple rendering of currency data. This function can also convert the specified amount of currency into a different target currency:
|
||||
|
||||
::: InvenTree.helpers_model.render_currency
|
||||
options:
|
||||
show_docstring_description: false
|
||||
show_source: False
|
||||
|
||||
|
||||
#### Example
|
||||
|
||||
```html
|
||||
{% raw %}
|
||||
{% load report %}
|
||||
@@ -124,20 +166,40 @@ Total Price: {% render_currency order.total_price currency='NZD' decimal_places=
|
||||
{% endraw %}
|
||||
```
|
||||
|
||||
The following keyword arguments are available to the `render_currency` function:
|
||||
|
||||
| Argument | Description |
|
||||
| --- | --- |
|
||||
| currency | Specify the currency code to render in (will attempt conversion if different to provided currency) |
|
||||
| decimal_places | Specify the number of decimal places to render |
|
||||
| min_decimal_places | Specify the minimum number of decimal places to render |
|
||||
| max_decimal_places | Specify the maximum number of decimal places to render |
|
||||
| include_symbol | Include currency symbol in rendered value (default = True) |
|
||||
|
||||
## Maths Operations
|
||||
|
||||
Simple mathematical operators are available, as demonstrated in the example template below:
|
||||
|
||||
### add
|
||||
|
||||
::: report.templatetags.report.add
|
||||
options:
|
||||
show_docstring_description: false
|
||||
show_source: False
|
||||
|
||||
### subtract
|
||||
|
||||
::: report.templatetags.report.subtract
|
||||
options:
|
||||
show_docstring_description: false
|
||||
show_source: False
|
||||
|
||||
### multiply
|
||||
|
||||
::: report.templatetags.report.multiply
|
||||
options:
|
||||
show_docstring_description: false
|
||||
show_source: False
|
||||
|
||||
### divide
|
||||
|
||||
::: report.templatetags.report.divide
|
||||
options:
|
||||
show_docstring_description: false
|
||||
show_source: False
|
||||
|
||||
### Example
|
||||
|
||||
```html
|
||||
{% raw %}
|
||||
<!-- Load the report helper functions -->
|
||||
@@ -170,10 +232,15 @@ Total: {% multiply line.purchase_price line.quantity %}<br>
|
||||
|
||||
*Media files* are any files uploaded to the InvenTree server by the user. These are stored under the `/media/` directory and can be accessed for use in custom reports or labels.
|
||||
|
||||
### Uploaded Images
|
||||
### uploaded_image
|
||||
|
||||
You can access an uploaded image file if you know the *path* of the image, relative to the top-level `/media/` directory. To load the image into a report, use the `{% raw %}{% uploaded_image ... %}{% endraw %}` tag:
|
||||
|
||||
::: report.templatetags.report.uploaded_image
|
||||
options:
|
||||
show_docstring_description: false
|
||||
show_source: False
|
||||
|
||||
```html
|
||||
{% raw %}
|
||||
<!-- Load the report helper functions -->
|
||||
@@ -199,7 +266,12 @@ The `{% raw %}{% uploaded_image %}{% endraw %}` tag supports some optional param
|
||||
{% endraw %}```
|
||||
|
||||
|
||||
### SVG Images
|
||||
### encode_svg_image
|
||||
|
||||
::: report.templatetags.report.encode_svg_image
|
||||
options:
|
||||
show_docstring_description: false
|
||||
show_source: False
|
||||
|
||||
SVG images need to be handled in a slightly different manner. When embedding an uploaded SVG image, use the `{% raw %}{% encode_svg_image ... %}{% endraw %}` tag:
|
||||
|
||||
@@ -211,10 +283,15 @@ SVG images need to be handled in a slightly different manner. When embedding an
|
||||
{% endraw %}
|
||||
```
|
||||
|
||||
### Part images
|
||||
### part_image
|
||||
|
||||
A shortcut function is provided for rendering an image associated with a Part instance. You can render the image of the part using the `{% raw %}{% part_image ... %}{% endraw %}` template tag:
|
||||
|
||||
::: report.templatetags.report.part_image
|
||||
options:
|
||||
show_docstring_description: false
|
||||
show_source: False
|
||||
|
||||
```html
|
||||
{% raw %}
|
||||
<!-- Load the report helper functions -->
|
||||
@@ -225,7 +302,7 @@ A shortcut function is provided for rendering an image associated with a Part in
|
||||
|
||||
#### Image Arguments
|
||||
|
||||
Any optional arguments which can be used in the [uploaded_image tag](#uploaded-images) can be used here too.
|
||||
Any optional arguments which can be used in the [uploaded_image tag](#uploaded_image) can be used here too.
|
||||
|
||||
#### Image Variations
|
||||
|
||||
@@ -243,10 +320,15 @@ The *Part* model supports *preview* (256 x 256) and *thumbnail* (128 x 128) vers
|
||||
```
|
||||
|
||||
|
||||
### Company Images
|
||||
### company_image
|
||||
|
||||
A shortcut function is provided for rendering an image associated with a Company instance. You can render the image of the company using the `{% raw %}{% company_image ... %}{% endraw %}` template tag:
|
||||
|
||||
::: report.templatetags.report.company_image
|
||||
options:
|
||||
show_docstring_description: false
|
||||
show_source: False
|
||||
|
||||
```html
|
||||
{% raw %}
|
||||
<!-- Load the report helper functions -->
|
||||
@@ -326,7 +408,14 @@ You can add asset images to the reports and labels by using the `{% raw %}{% ass
|
||||
|
||||
## Part Parameters
|
||||
|
||||
If you need to load a part parameter for a particular Part, within the context of your template, you can use the `part_parameter` template tag.
|
||||
If you need to load a part parameter for a particular Part, within the context of your template, you can use the `part_parameter` template tag:
|
||||
|
||||
::: report.templatetags.report.part_parameter
|
||||
options:
|
||||
show_docstring_description: false
|
||||
show_source: False
|
||||
|
||||
### Example
|
||||
|
||||
The following example assumes that you have a report or label which contains a valid [Part](../part/part.md) instance:
|
||||
|
||||
|
||||
@@ -177,7 +177,7 @@ Asset files can be rendered directly into the template as follows
|
||||
If the requested asset name does not match the name of an uploaded asset, the template will continue without loading the image.
|
||||
|
||||
!!! info "Assets location"
|
||||
You need to ensure your asset images to the report/assets directory in the [data directory](../start/intro.md#file-storage). Upload new assets via the [admin interface](../settings/admin.md) to ensure they are uploaded to the correct location on the server.
|
||||
Upload new assets via the [admin interface](../settings/admin.md) to ensure they are uploaded to the correct location on the server.
|
||||
|
||||
|
||||
## Report Snippets
|
||||
|
||||
@@ -27,7 +27,6 @@ The version information contains the following information extracted form the in
|
||||
| Target | No | ubuntu:20.04 | environment: `INVENTREE_PKG_TARGET` |
|
||||
| Active plugins | Yes | [{'name': 'InvenTreeBarcode', 'slug': 'inventreebarcode', 'version': '2.0.0'}] | instance |
|
||||
|
||||
|
||||
### Installer codes
|
||||
|
||||
The installer code is used to identify the way InvenTree was installed. If you vendor InvenTree, you can and should set the installer code to your own value to make sure debugging goes smoothly.
|
||||
@@ -68,11 +67,15 @@ Next you can start configuring the connection. Either use the config file or set
|
||||
| `ldap.always_update_user` | `INVENTREE_LDAP_ALWAYS_UPDATE_USER` | Always update the user on each login, default: `true` |
|
||||
| `ldap.cache_timeout` | `INVENTREE_LDAP_CACHE_TIMEOUT` | cache timeout to reduce traffic with LDAP server, default: `3600` (1h) |
|
||||
| `ldap.group_search` | `INVENTREE_LDAP_GROUP_SEARCH` | Base LDAP DN for group searching; required to enable group features |
|
||||
| `ldap.group_object_class` | `INVENTREE_LDAP_GROUP_OBJECT_CLASS` | The string to pass to the LDAP group search `(objectClass=<...>)`, default: `groupOfUniqueNames` |
|
||||
| `ldap.mirror_groups` | `INVENTREE_LDAP_MIRROR_GROUPS` | If `True`, mirror a user's LDAP group membership in the Django database, default: `False` |
|
||||
| `ldap.group_type_class` | `INVENTREE_LDAP_GROUP_TYPE_CLASS` | The group class to be imported from `django_auth_ldap.config` as a string, default: `'GroupOfUniqueNamesType'`|
|
||||
| `ldap.group_type_class_args` | `INVENTREE_LDAP_GROUP_TYPE_CLASS_ARGS` | A `list` of positional args to pass to the LDAP group type class, default `[]` |
|
||||
| `ldap.group_type_class_kwargs` | `INVENTREE_LDAP_GROUP_TYPE_CLASS_KWARGS` | A `dict` of keyword args to pass to the LDAP group type class, default `{'name_attr': 'cn'}` |
|
||||
| `ldap.require_group` | `INVENTREE_LDAP_REQUIRE_GROUP` | If set, users _must_ be in this group to log in to InvenTree |
|
||||
| `ldap.deny_group` | `INVENTREE_LDAP_DENY_GROUP` | If set, users _must not_ be in this group to log in to InvenTree |
|
||||
| `ldap.user_flags_by_group` | `INVENTREE_LDAP_USER_FLAGS_BY_GROUP` | LDAP group to InvenTree user flag map, can be json if used as env, in yml directly specify the object. See config template for example, default: `{}` |
|
||||
|
||||
|
||||
## Tracing support
|
||||
|
||||
Starting with 0.14.0 InvenTree supports sending traces, logs and metrics to OpenTelemetry compatible endpoints (both HTTP and gRPC). A [list of vendors](https://opentelemetry.io/ecosystem/vendors) is available on the project site.
|
||||
@@ -99,4 +102,4 @@ If your InvenTree instance is used in a multi-site environment, you can enable m
|
||||
| Environment Variable | Config Key | Description | Default |
|
||||
| --- | --- | --- | --- |
|
||||
| INVENTREE_SITE_MULTI | site_multi | Enable multiple sites | False |
|
||||
| INVENTREE_SITE_ID | site_id | Specify a fixed site ID | *Not specified* |
|
||||
| INVENTREE_SITE_ID | site_id | Specify a fixed site ID | _Not specified_ |
|
||||
|
||||
@@ -46,6 +46,14 @@ Environment variable settings generally use the `INVENTREE_` prefix, and are all
|
||||
!!! warning "Available Variables"
|
||||
Some configuration options cannot be set via environment variables. Refer to the documentation below.
|
||||
|
||||
#### List Values
|
||||
|
||||
To specify a list value in an environment variable, use a comma-separated list. For example, to specify a list of trusted origins:
|
||||
|
||||
```bash
|
||||
INVENTREE_TRUSTED_ORIGINS='https://inventree.example.com:8443,https://stock.example.com:8443'
|
||||
```
|
||||
|
||||
## Basic Options
|
||||
|
||||
The following basic options are available:
|
||||
@@ -57,7 +65,9 @@ The following basic options are available:
|
||||
| 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 |
|
||||
|
||||
+12
-6
@@ -53,6 +53,8 @@ def check_link(url) -> bool:
|
||||
if url in cache:
|
||||
return True
|
||||
|
||||
print(f'Checking external URL: {url}')
|
||||
|
||||
attempts = 5
|
||||
|
||||
while attempts > 0:
|
||||
@@ -66,6 +68,8 @@ def check_link(url) -> bool:
|
||||
|
||||
attempts -= 1
|
||||
|
||||
print(f' - URL check failed with status code {response.status_code}')
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@@ -151,17 +155,19 @@ def define_env(env):
|
||||
if not os.path.exists(file_path):
|
||||
raise FileNotFoundError(f'Source file {filename} does not exist.')
|
||||
|
||||
repo_url = get_repo_url(raw=raw)
|
||||
|
||||
if raw:
|
||||
url = f'{repo_url}/{branch}/{filename}'
|
||||
else:
|
||||
url = f'{repo_url}/blob/{branch}/{filename}'
|
||||
# Construct repo URL
|
||||
repo_url = get_repo_url(raw=False)
|
||||
url = f'{repo_url}/blob/{branch}/{filename}'
|
||||
|
||||
# Check that the URL exists before returning it
|
||||
if not check_link(url):
|
||||
raise FileNotFoundError(f'URL {url} does not exist.')
|
||||
|
||||
if raw:
|
||||
# If requesting the 'raw' URL, take this into account here...
|
||||
repo_url = get_repo_url(raw=True)
|
||||
url = f'{repo_url}/{branch}/{filename}'
|
||||
|
||||
return url
|
||||
|
||||
@env.macro
|
||||
|
||||
+107
-107
@@ -311,17 +311,17 @@ mkdocs-git-revision-date-localized-plugin==1.3.0 \
|
||||
--hash=sha256:439e2f14582204050a664c258861c325064d97cdc848c541e48bb034a6c4d0cb \
|
||||
--hash=sha256:c99377ee119372d57a9e47cff4e68f04cce634a74831c06bc89b33e456e840a1
|
||||
# via -r docs/requirements.in
|
||||
mkdocs-include-markdown-plugin==7.0.0 \
|
||||
--hash=sha256:a8eac8f2e6aa391d82d1d5e473b819b52393d91464060c02db5741834fe9008b \
|
||||
--hash=sha256:bf8d19245ae3fb2eea395888e80c60bc91806a0d879279707d707896c24319c3
|
||||
mkdocs-include-markdown-plugin==7.0.1 \
|
||||
--hash=sha256:4abd341cb1c5eac60ddd1a21540fdff714f1acc99e3b26f37641db60cd175a8d \
|
||||
--hash=sha256:d619c206109dab4bab281e2d29b645838d55b0576c761b1fbb17e6bff1170206
|
||||
# via -r docs/requirements.in
|
||||
mkdocs-macros-plugin==1.3.7 \
|
||||
--hash=sha256:02432033a5b77fb247d6ec7924e72fc4ceec264165b1644ab8d0dc159c22ce59 \
|
||||
--hash=sha256:17c7fd1a49b94defcdb502fd453d17a1e730f8836523379d21292eb2be4cb523
|
||||
# via -r docs/requirements.in
|
||||
mkdocs-material==9.5.43 \
|
||||
--hash=sha256:4aae0664c456fd12837a3192e0225c17960ba8bf55d7f0a7daef7e4b0b914a34 \
|
||||
--hash=sha256:83be7ff30b65a1e4930dfa4ab911e75780a3afc9583d162692e434581cb46979
|
||||
mkdocs-material==9.5.44 \
|
||||
--hash=sha256:47015f9c167d58a5ff5e682da37441fc4d66a1c79334bfc08d774763cacf69ca \
|
||||
--hash=sha256:f3a6c968e524166b3f3ed1fb97d3ed3e0091183b0545cedf7156a2a6804c56c0
|
||||
# via -r docs/requirements.in
|
||||
mkdocs-material-extensions==1.3.1 \
|
||||
--hash=sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443 \
|
||||
@@ -331,9 +331,9 @@ mkdocs-simple-hooks==0.1.5 \
|
||||
--hash=sha256:dddbdf151a18723c9302a133e5cf79538be8eb9d274e8e07d2ac3ac34890837c \
|
||||
--hash=sha256:efeabdbb98b0850a909adee285f3404535117159d5cb3a34f541d6eaa644d50a
|
||||
# via -r docs/requirements.in
|
||||
mkdocstrings[python]==0.26.2 \
|
||||
--hash=sha256:1248f3228464f3b8d1a15bd91249ce1701fe3104ac517a5f167a0e01ca850ba5 \
|
||||
--hash=sha256:34a8b50f1e6cfd29546c6c09fbe02154adfb0b361bb758834bf56aa284ba876e
|
||||
mkdocstrings[python]==0.27.0 \
|
||||
--hash=sha256:16adca6d6b0a1f9e0c07ff0b02ced8e16f228a9d65a37c063ec4c14d7b76a657 \
|
||||
--hash=sha256:6ceaa7ea830770959b55a16203ac63da24badd71325b96af950e59fd37366332
|
||||
# via
|
||||
# -r docs/requirements.in
|
||||
# mkdocstrings-python
|
||||
@@ -453,101 +453,101 @@ pyyaml-env-tag==0.1 \
|
||||
--hash=sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb \
|
||||
--hash=sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069
|
||||
# via mkdocs
|
||||
regex==2024.9.11 \
|
||||
--hash=sha256:01c2acb51f8a7d6494c8c5eafe3d8e06d76563d8a8a4643b37e9b2dd8a2ff623 \
|
||||
--hash=sha256:02087ea0a03b4af1ed6ebab2c54d7118127fee8d71b26398e8e4b05b78963199 \
|
||||
--hash=sha256:040562757795eeea356394a7fb13076ad4f99d3c62ab0f8bdfb21f99a1f85664 \
|
||||
--hash=sha256:042c55879cfeb21a8adacc84ea347721d3d83a159da6acdf1116859e2427c43f \
|
||||
--hash=sha256:079400a8269544b955ffa9e31f186f01d96829110a3bf79dc338e9910f794fca \
|
||||
--hash=sha256:07f45f287469039ffc2c53caf6803cd506eb5f5f637f1d4acb37a738f71dd066 \
|
||||
--hash=sha256:09d77559e80dcc9d24570da3745ab859a9cf91953062e4ab126ba9d5993688ca \
|
||||
--hash=sha256:0cbff728659ce4bbf4c30b2a1be040faafaa9eca6ecde40aaff86f7889f4ab39 \
|
||||
--hash=sha256:0e12c481ad92d129c78f13a2a3662317e46ee7ef96c94fd332e1c29131875b7d \
|
||||
--hash=sha256:0ea51dcc0835eea2ea31d66456210a4e01a076d820e9039b04ae8d17ac11dee6 \
|
||||
--hash=sha256:0ffbcf9221e04502fc35e54d1ce9567541979c3fdfb93d2c554f0ca583a19b35 \
|
||||
--hash=sha256:1494fa8725c285a81d01dc8c06b55287a1ee5e0e382d8413adc0a9197aac6408 \
|
||||
--hash=sha256:16e13a7929791ac1216afde26f712802e3df7bf0360b32e4914dca3ab8baeea5 \
|
||||
--hash=sha256:18406efb2f5a0e57e3a5881cd9354c1512d3bb4f5c45d96d110a66114d84d23a \
|
||||
--hash=sha256:18e707ce6c92d7282dfce370cd205098384b8ee21544e7cb29b8aab955b66fa9 \
|
||||
--hash=sha256:220e92a30b426daf23bb67a7962900ed4613589bab80382be09b48896d211e92 \
|
||||
--hash=sha256:23b30c62d0f16827f2ae9f2bb87619bc4fba2044911e2e6c2eb1af0161cdb766 \
|
||||
--hash=sha256:23f9985c8784e544d53fc2930fc1ac1a7319f5d5332d228437acc9f418f2f168 \
|
||||
--hash=sha256:297f54910247508e6e5cae669f2bc308985c60540a4edd1c77203ef19bfa63ca \
|
||||
--hash=sha256:2b08fce89fbd45664d3df6ad93e554b6c16933ffa9d55cb7e01182baaf971508 \
|
||||
--hash=sha256:2cce2449e5927a0bf084d346da6cd5eb016b2beca10d0013ab50e3c226ffc0df \
|
||||
--hash=sha256:313ea15e5ff2a8cbbad96ccef6be638393041b0a7863183c2d31e0c6116688cf \
|
||||
--hash=sha256:323c1f04be6b2968944d730e5c2091c8c89767903ecaa135203eec4565ed2b2b \
|
||||
--hash=sha256:35f4a6f96aa6cb3f2f7247027b07b15a374f0d5b912c0001418d1d55024d5cb4 \
|
||||
--hash=sha256:3b37fa423beefa44919e009745ccbf353d8c981516e807995b2bd11c2c77d268 \
|
||||
--hash=sha256:3ce4f1185db3fbde8ed8aa223fc9620f276c58de8b0d4f8cc86fd1360829edb6 \
|
||||
--hash=sha256:46989629904bad940bbec2106528140a218b4a36bb3042d8406980be1941429c \
|
||||
--hash=sha256:4838e24ee015101d9f901988001038f7f0d90dc0c3b115541a1365fb439add62 \
|
||||
--hash=sha256:49b0e06786ea663f933f3710a51e9385ce0cba0ea56b67107fd841a55d56a231 \
|
||||
--hash=sha256:4db21ece84dfeefc5d8a3863f101995de646c6cb0536952c321a2650aa202c36 \
|
||||
--hash=sha256:54c4a097b8bc5bb0dfc83ae498061d53ad7b5762e00f4adaa23bee22b012e6ba \
|
||||
--hash=sha256:54d9ff35d4515debf14bc27f1e3b38bfc453eff3220f5bce159642fa762fe5d4 \
|
||||
--hash=sha256:55b96e7ce3a69a8449a66984c268062fbaa0d8ae437b285428e12797baefce7e \
|
||||
--hash=sha256:57fdd2e0b2694ce6fc2e5ccf189789c3e2962916fb38779d3e3521ff8fe7a822 \
|
||||
--hash=sha256:587d4af3979376652010e400accc30404e6c16b7df574048ab1f581af82065e4 \
|
||||
--hash=sha256:5b513b6997a0b2f10e4fd3a1313568e373926e8c252bd76c960f96fd039cd28d \
|
||||
--hash=sha256:5ddcd9a179c0a6fa8add279a4444015acddcd7f232a49071ae57fa6e278f1f71 \
|
||||
--hash=sha256:6113c008a7780792efc80f9dfe10ba0cd043cbf8dc9a76ef757850f51b4edc50 \
|
||||
--hash=sha256:635a1d96665f84b292e401c3d62775851aedc31d4f8784117b3c68c4fcd4118d \
|
||||
--hash=sha256:64ce2799bd75039b480cc0360907c4fb2f50022f030bf9e7a8705b636e408fad \
|
||||
--hash=sha256:69dee6a020693d12a3cf892aba4808fe168d2a4cef368eb9bf74f5398bfd4ee8 \
|
||||
--hash=sha256:6a2644a93da36c784e546de579ec1806bfd2763ef47babc1b03d765fe560c9f8 \
|
||||
--hash=sha256:6b41e1adc61fa347662b09398e31ad446afadff932a24807d3ceb955ed865cc8 \
|
||||
--hash=sha256:6c188c307e8433bcb63dc1915022deb553b4203a70722fc542c363bf120a01fd \
|
||||
--hash=sha256:6edd623bae6a737f10ce853ea076f56f507fd7726bee96a41ee3d68d347e4d16 \
|
||||
--hash=sha256:73d6d2f64f4d894c96626a75578b0bf7d9e56dcda8c3d037a2118fdfe9b1c664 \
|
||||
--hash=sha256:7a22ccefd4db3f12b526eccb129390942fe874a3a9fdbdd24cf55773a1faab1a \
|
||||
--hash=sha256:7fb89ee5d106e4a7a51bce305ac4efb981536301895f7bdcf93ec92ae0d91c7f \
|
||||
--hash=sha256:846bc79ee753acf93aef4184c040d709940c9d001029ceb7b7a52747b80ed2dd \
|
||||
--hash=sha256:85ab7824093d8f10d44330fe1e6493f756f252d145323dd17ab6b48733ff6c0a \
|
||||
--hash=sha256:8dee5b4810a89447151999428fe096977346cf2f29f4d5e29609d2e19e0199c9 \
|
||||
--hash=sha256:8e5fb5f77c8745a60105403a774fe2c1759b71d3e7b4ca237a5e67ad066c7199 \
|
||||
--hash=sha256:98eeee2f2e63edae2181c886d7911ce502e1292794f4c5ee71e60e23e8d26b5d \
|
||||
--hash=sha256:9d4a76b96f398697fe01117093613166e6aa8195d63f1b4ec3f21ab637632963 \
|
||||
--hash=sha256:9e8719792ca63c6b8340380352c24dcb8cd7ec49dae36e963742a275dfae6009 \
|
||||
--hash=sha256:a0b2b80321c2ed3fcf0385ec9e51a12253c50f146fddb2abbb10f033fe3d049a \
|
||||
--hash=sha256:a4cc92bb6db56ab0c1cbd17294e14f5e9224f0cc6521167ef388332604e92679 \
|
||||
--hash=sha256:a738b937d512b30bf75995c0159c0ddf9eec0775c9d72ac0202076c72f24aa96 \
|
||||
--hash=sha256:a8f877c89719d759e52783f7fe6e1c67121076b87b40542966c02de5503ace42 \
|
||||
--hash=sha256:a906ed5e47a0ce5f04b2c981af1c9acf9e8696066900bf03b9d7879a6f679fc8 \
|
||||
--hash=sha256:ae2941333154baff9838e88aa71c1d84f4438189ecc6021a12c7573728b5838e \
|
||||
--hash=sha256:b0d0a6c64fcc4ef9c69bd5b3b3626cc3776520a1637d8abaa62b9edc147a58f7 \
|
||||
--hash=sha256:b5b029322e6e7b94fff16cd120ab35a253236a5f99a79fb04fda7ae71ca20ae8 \
|
||||
--hash=sha256:b7aaa315101c6567a9a45d2839322c51c8d6e81f67683d529512f5bcfb99c802 \
|
||||
--hash=sha256:be1c8ed48c4c4065ecb19d882a0ce1afe0745dfad8ce48c49586b90a55f02366 \
|
||||
--hash=sha256:c0256beda696edcf7d97ef16b2a33a8e5a875affd6fa6567b54f7c577b30a137 \
|
||||
--hash=sha256:c157bb447303070f256e084668b702073db99bbb61d44f85d811025fcf38f784 \
|
||||
--hash=sha256:c57d08ad67aba97af57a7263c2d9006d5c404d721c5f7542f077f109ec2a4a29 \
|
||||
--hash=sha256:c69ada171c2d0e97a4b5aa78fbb835e0ffbb6b13fc5da968c09811346564f0d3 \
|
||||
--hash=sha256:c94bb0a9f1db10a1d16c00880bdebd5f9faf267273b8f5bd1878126e0fbde771 \
|
||||
--hash=sha256:cb130fccd1a37ed894824b8c046321540263013da72745d755f2d35114b81a60 \
|
||||
--hash=sha256:ced479f601cd2f8ca1fd7b23925a7e0ad512a56d6e9476f79b8f381d9d37090a \
|
||||
--hash=sha256:d05ac6fa06959c4172eccd99a222e1fbf17b5670c4d596cb1e5cde99600674c4 \
|
||||
--hash=sha256:d552c78411f60b1fdaafd117a1fca2f02e562e309223b9d44b7de8be451ec5e0 \
|
||||
--hash=sha256:dd4490a33eb909ef5078ab20f5f000087afa2a4daa27b4c072ccb3cb3050ad84 \
|
||||
--hash=sha256:df5cbb1fbc74a8305b6065d4ade43b993be03dbe0f8b30032cced0d7740994bd \
|
||||
--hash=sha256:e28f9faeb14b6f23ac55bfbbfd3643f5c7c18ede093977f1df249f73fd22c7b1 \
|
||||
--hash=sha256:e464b467f1588e2c42d26814231edecbcfe77f5ac414d92cbf4e7b55b2c2a776 \
|
||||
--hash=sha256:e4c22e1ac1f1ec1e09f72e6c44d8f2244173db7eb9629cc3a346a8d7ccc31142 \
|
||||
--hash=sha256:e53b5fbab5d675aec9f0c501274c467c0f9a5d23696cfc94247e1fb56501ed89 \
|
||||
--hash=sha256:e93f1c331ca8e86fe877a48ad64e77882c0c4da0097f2212873a69bbfea95d0c \
|
||||
--hash=sha256:e997fd30430c57138adc06bba4c7c2968fb13d101e57dd5bb9355bf8ce3fa7e8 \
|
||||
--hash=sha256:e9a091b0550b3b0207784a7d6d0f1a00d1d1c8a11699c1a4d93db3fbefc3ad35 \
|
||||
--hash=sha256:eab4bb380f15e189d1313195b062a6aa908f5bd687a0ceccd47c8211e9cf0d4a \
|
||||
--hash=sha256:eb1ae19e64c14c7ec1995f40bd932448713d3c73509e82d8cd7744dc00e29e86 \
|
||||
--hash=sha256:ecea58b43a67b1b79805f1a0255730edaf5191ecef84dbc4cc85eb30bc8b63b9 \
|
||||
--hash=sha256:ee439691d8c23e76f9802c42a95cfeebf9d47cf4ffd06f18489122dbb0a7ad64 \
|
||||
--hash=sha256:eee9130eaad130649fd73e5cd92f60e55708952260ede70da64de420cdcad554 \
|
||||
--hash=sha256:f47cd43a5bfa48f86925fe26fbdd0a488ff15b62468abb5d2a1e092a4fb10e85 \
|
||||
--hash=sha256:f6fff13ef6b5f29221d6904aa816c34701462956aa72a77f1f151a8ec4f56aeb \
|
||||
--hash=sha256:f745ec09bc1b0bd15cfc73df6fa4f726dcc26bb16c23a03f9e3367d357eeedd0 \
|
||||
--hash=sha256:f8404bf61298bb6f8224bb9176c1424548ee1181130818fcd2cbffddc768bed8 \
|
||||
--hash=sha256:f9268774428ec173654985ce55fc6caf4c6d11ade0f6f914d48ef4719eb05ebb \
|
||||
--hash=sha256:faa3c142464efec496967359ca99696c896c591c56c53506bac1ad465f66e919
|
||||
regex==2024.11.6 \
|
||||
--hash=sha256:02a02d2bb04fec86ad61f3ea7f49c015a0681bf76abb9857f945d26159d2968c \
|
||||
--hash=sha256:02e28184be537f0e75c1f9b2f8847dc51e08e6e171c6bde130b2687e0c33cf60 \
|
||||
--hash=sha256:040df6fe1a5504eb0f04f048e6d09cd7c7110fef851d7c567a6b6e09942feb7d \
|
||||
--hash=sha256:068376da5a7e4da51968ce4c122a7cd31afaaec4fccc7856c92f63876e57b51d \
|
||||
--hash=sha256:06eb1be98df10e81ebaded73fcd51989dcf534e3c753466e4b60c4697a003b67 \
|
||||
--hash=sha256:072623554418a9911446278f16ecb398fb3b540147a7828c06e2011fa531e773 \
|
||||
--hash=sha256:086a27a0b4ca227941700e0b31425e7a28ef1ae8e5e05a33826e17e47fbfdba0 \
|
||||
--hash=sha256:08986dce1339bc932923e7d1232ce9881499a0e02925f7402fb7c982515419ef \
|
||||
--hash=sha256:0a86e7eeca091c09e021db8eb72d54751e527fa47b8d5787caf96d9831bd02ad \
|
||||
--hash=sha256:0c32f75920cf99fe6b6c539c399a4a128452eaf1af27f39bce8909c9a3fd8cbe \
|
||||
--hash=sha256:0d7f453dca13f40a02b79636a339c5b62b670141e63efd511d3f8f73fba162b3 \
|
||||
--hash=sha256:1062b39a0a2b75a9c694f7a08e7183a80c63c0d62b301418ffd9c35f55aaa114 \
|
||||
--hash=sha256:13291b39131e2d002a7940fb176e120bec5145f3aeb7621be6534e46251912c4 \
|
||||
--hash=sha256:149f5008d286636e48cd0b1dd65018548944e495b0265b45e1bffecce1ef7f39 \
|
||||
--hash=sha256:164d8b7b3b4bcb2068b97428060b2a53be050085ef94eca7f240e7947f1b080e \
|
||||
--hash=sha256:167ed4852351d8a750da48712c3930b031f6efdaa0f22fa1933716bfcd6bf4a3 \
|
||||
--hash=sha256:1c4de13f06a0d54fa0d5ab1b7138bfa0d883220965a29616e3ea61b35d5f5fc7 \
|
||||
--hash=sha256:202eb32e89f60fc147a41e55cb086db2a3f8cb82f9a9a88440dcfc5d37faae8d \
|
||||
--hash=sha256:220902c3c5cc6af55d4fe19ead504de80eb91f786dc102fbd74894b1551f095e \
|
||||
--hash=sha256:2b3361af3198667e99927da8b84c1b010752fa4b1115ee30beaa332cabc3ef1a \
|
||||
--hash=sha256:2c89a8cc122b25ce6945f0423dc1352cb9593c68abd19223eebbd4e56612c5b7 \
|
||||
--hash=sha256:2d548dafee61f06ebdb584080621f3e0c23fff312f0de1afc776e2a2ba99a74f \
|
||||
--hash=sha256:2e34b51b650b23ed3354b5a07aab37034d9f923db2a40519139af34f485f77d0 \
|
||||
--hash=sha256:32f9a4c643baad4efa81d549c2aadefaeba12249b2adc5af541759237eee1c54 \
|
||||
--hash=sha256:3a51ccc315653ba012774efca4f23d1d2a8a8f278a6072e29c7147eee7da446b \
|
||||
--hash=sha256:3cde6e9f2580eb1665965ce9bf17ff4952f34f5b126beb509fee8f4e994f143c \
|
||||
--hash=sha256:40291b1b89ca6ad8d3f2b82782cc33807f1406cf68c8d440861da6304d8ffbbd \
|
||||
--hash=sha256:41758407fc32d5c3c5de163888068cfee69cb4c2be844e7ac517a52770f9af57 \
|
||||
--hash=sha256:4181b814e56078e9b00427ca358ec44333765f5ca1b45597ec7446d3a1ef6e34 \
|
||||
--hash=sha256:4f51f88c126370dcec4908576c5a627220da6c09d0bff31cfa89f2523843316d \
|
||||
--hash=sha256:50153825ee016b91549962f970d6a4442fa106832e14c918acd1c8e479916c4f \
|
||||
--hash=sha256:5056b185ca113c88e18223183aa1a50e66507769c9640a6ff75859619d73957b \
|
||||
--hash=sha256:5071b2093e793357c9d8b2929dfc13ac5f0a6c650559503bb81189d0a3814519 \
|
||||
--hash=sha256:525eab0b789891ac3be914d36893bdf972d483fe66551f79d3e27146191a37d4 \
|
||||
--hash=sha256:52fb28f528778f184f870b7cf8f225f5eef0a8f6e3778529bdd40c7b3920796a \
|
||||
--hash=sha256:5478c6962ad548b54a591778e93cd7c456a7a29f8eca9c49e4f9a806dcc5d638 \
|
||||
--hash=sha256:5670bce7b200273eee1840ef307bfa07cda90b38ae56e9a6ebcc9f50da9c469b \
|
||||
--hash=sha256:5704e174f8ccab2026bd2f1ab6c510345ae8eac818b613d7d73e785f1310f839 \
|
||||
--hash=sha256:59dfe1ed21aea057a65c6b586afd2a945de04fc7db3de0a6e3ed5397ad491b07 \
|
||||
--hash=sha256:5e7e351589da0850c125f1600a4c4ba3c722efefe16b297de54300f08d734fbf \
|
||||
--hash=sha256:63b13cfd72e9601125027202cad74995ab26921d8cd935c25f09c630436348ff \
|
||||
--hash=sha256:658f90550f38270639e83ce492f27d2c8d2cd63805c65a13a14d36ca126753f0 \
|
||||
--hash=sha256:684d7a212682996d21ca12ef3c17353c021fe9de6049e19ac8481ec35574a70f \
|
||||
--hash=sha256:69ab78f848845569401469da20df3e081e6b5a11cb086de3eed1d48f5ed57c95 \
|
||||
--hash=sha256:6f44ec28b1f858c98d3036ad5d7d0bfc568bdd7a74f9c24e25f41ef1ebfd81a4 \
|
||||
--hash=sha256:70b7fa6606c2881c1db9479b0eaa11ed5dfa11c8d60a474ff0e095099f39d98e \
|
||||
--hash=sha256:764e71f22ab3b305e7f4c21f1a97e1526a25ebdd22513e251cf376760213da13 \
|
||||
--hash=sha256:7ab159b063c52a0333c884e4679f8d7a85112ee3078fe3d9004b2dd875585519 \
|
||||
--hash=sha256:805e6b60c54bf766b251e94526ebad60b7de0c70f70a4e6210ee2891acb70bf2 \
|
||||
--hash=sha256:8447d2d39b5abe381419319f942de20b7ecd60ce86f16a23b0698f22e1b70008 \
|
||||
--hash=sha256:86fddba590aad9208e2fa8b43b4c098bb0ec74f15718bb6a704e3c63e2cef3e9 \
|
||||
--hash=sha256:89d75e7293d2b3e674db7d4d9b1bee7f8f3d1609428e293771d1a962617150cc \
|
||||
--hash=sha256:93c0b12d3d3bc25af4ebbf38f9ee780a487e8bf6954c115b9f015822d3bb8e48 \
|
||||
--hash=sha256:94d87b689cdd831934fa3ce16cc15cd65748e6d689f5d2b8f4f4df2065c9fa20 \
|
||||
--hash=sha256:9714398225f299aa85267fd222f7142fcb5c769e73d7733344efc46f2ef5cf89 \
|
||||
--hash=sha256:982e6d21414e78e1f51cf595d7f321dcd14de1f2881c5dc6a6e23bbbbd68435e \
|
||||
--hash=sha256:997d6a487ff00807ba810e0f8332c18b4eb8d29463cfb7c820dc4b6e7562d0cf \
|
||||
--hash=sha256:a03e02f48cd1abbd9f3b7e3586d97c8f7a9721c436f51a5245b3b9483044480b \
|
||||
--hash=sha256:a36fdf2af13c2b14738f6e973aba563623cb77d753bbbd8d414d18bfaa3105dd \
|
||||
--hash=sha256:a6ba92c0bcdf96cbf43a12c717eae4bc98325ca3730f6b130ffa2e3c3c723d84 \
|
||||
--hash=sha256:a7c2155f790e2fb448faed6dd241386719802296ec588a8b9051c1f5c481bc29 \
|
||||
--hash=sha256:a93c194e2df18f7d264092dc8539b8ffb86b45b899ab976aa15d48214138e81b \
|
||||
--hash=sha256:abfa5080c374a76a251ba60683242bc17eeb2c9818d0d30117b4486be10c59d3 \
|
||||
--hash=sha256:ac10f2c4184420d881a3475fb2c6f4d95d53a8d50209a2500723d831036f7c45 \
|
||||
--hash=sha256:ad182d02e40de7459b73155deb8996bbd8e96852267879396fb274e8700190e3 \
|
||||
--hash=sha256:b2837718570f95dd41675328e111345f9b7095d821bac435aac173ac80b19983 \
|
||||
--hash=sha256:b489578720afb782f6ccf2840920f3a32e31ba28a4b162e13900c3e6bd3f930e \
|
||||
--hash=sha256:b583904576650166b3d920d2bcce13971f6f9e9a396c673187f49811b2769dc7 \
|
||||
--hash=sha256:b85c2530be953a890eaffde05485238f07029600e8f098cdf1848d414a8b45e4 \
|
||||
--hash=sha256:b97c1e0bd37c5cd7902e65f410779d39eeda155800b65fc4d04cc432efa9bc6e \
|
||||
--hash=sha256:ba9b72e5643641b7d41fa1f6d5abda2c9a263ae835b917348fc3c928182ad467 \
|
||||
--hash=sha256:bb26437975da7dc36b7efad18aa9dd4ea569d2357ae6b783bf1118dabd9ea577 \
|
||||
--hash=sha256:bb8f74f2f10dbf13a0be8de623ba4f9491faf58c24064f32b65679b021ed0001 \
|
||||
--hash=sha256:bde01f35767c4a7899b7eb6e823b125a64de314a8ee9791367c9a34d56af18d0 \
|
||||
--hash=sha256:bec9931dfb61ddd8ef2ebc05646293812cb6b16b60cf7c9511a832b6f1854b55 \
|
||||
--hash=sha256:c36f9b6f5f8649bb251a5f3f66564438977b7ef8386a52460ae77e6070d309d9 \
|
||||
--hash=sha256:cdf58d0e516ee426a48f7b2c03a332a4114420716d55769ff7108c37a09951bf \
|
||||
--hash=sha256:d1cee317bfc014c2419a76bcc87f071405e3966da434e03e13beb45f8aced1a6 \
|
||||
--hash=sha256:d22326fcdef5e08c154280b71163ced384b428343ae16a5ab2b3354aed12436e \
|
||||
--hash=sha256:d3660c82f209655a06b587d55e723f0b813d3a7db2e32e5e7dc64ac2a9e86fde \
|
||||
--hash=sha256:da8f5fc57d1933de22a9e23eec290a0d8a5927a5370d24bda9a6abe50683fe62 \
|
||||
--hash=sha256:df951c5f4a1b1910f1a99ff42c473ff60f8225baa1cdd3539fe2819d9543e9df \
|
||||
--hash=sha256:e5364a4502efca094731680e80009632ad6624084aff9a23ce8c8c6820de3e51 \
|
||||
--hash=sha256:ea1bfda2f7162605f6e8178223576856b3d791109f15ea99a9f95c16a7636fb5 \
|
||||
--hash=sha256:f02f93b92358ee3f78660e43b4b0091229260c5d5c408d17d60bf26b6c900e86 \
|
||||
--hash=sha256:f056bf21105c2515c32372bbc057f43eb02aae2fda61052e2f7622c801f0b4e2 \
|
||||
--hash=sha256:f1ac758ef6aebfc8943560194e9fd0fa18bcb34d89fd8bd2af18183afd8da3a2 \
|
||||
--hash=sha256:f2a19f302cd1ce5dd01a9099aaa19cae6173306d1302a43b627f62e21cf18ac0 \
|
||||
--hash=sha256:f654882311409afb1d780b940234208a252322c24a93b442ca714d119e68086c \
|
||||
--hash=sha256:f65557897fc977a44ab205ea871b690adaef6b9da6afda4790a2484b04293a5f \
|
||||
--hash=sha256:f9d1e379028e0fc2ae3654bac3cbbef81bf3fd571272a42d56c24007979bafb6 \
|
||||
--hash=sha256:fdabbfc59f2c6edba2a6622c647b716e34e8e3867e0ab975412c5c2f79b82da2 \
|
||||
--hash=sha256:fdd6028445d2460f33136c55eeb1f601ab06d74cb3347132e1c24250187500d9 \
|
||||
--hash=sha256:ff590880083d60acc0433f9c3f713c51f7ac6ebb9adf889c79a261ecf541aa91
|
||||
# via mkdocs-material
|
||||
requests==2.32.3 \
|
||||
--hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760 \
|
||||
@@ -624,7 +624,7 @@ wcmatch==8.5.2 \
|
||||
--hash=sha256:17d3ad3758f9d0b5b4dedc770b65420d4dac62e680229c287bf24c9db856a478 \
|
||||
--hash=sha256:a70222b86dea82fb382dd87b73278c10756c138bd6f8f714e2183128887b9eb2
|
||||
# via mkdocs-include-markdown-plugin
|
||||
zipp==3.20.2 \
|
||||
--hash=sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350 \
|
||||
--hash=sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29
|
||||
zipp==3.21.0 \
|
||||
--hash=sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4 \
|
||||
--hash=sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931
|
||||
# via importlib-metadata
|
||||
|
||||
+1
-1
@@ -106,4 +106,4 @@ known_django="django"
|
||||
sections=["FUTURE","STDLIB","DJANGO","THIRDPARTY","FIRSTPARTY","LOCALFOLDER"]
|
||||
|
||||
[tool.codespell]
|
||||
ignore-words-list = ["assertIn","SME","intoto"]
|
||||
ignore-words-list = ["assertIn","SME","intoto","fitH"]
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 280
|
||||
INVENTREE_API_VERSION = 283
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v283 - 2024-11-20 : https://github.com/inventree/InvenTree/pull/8524
|
||||
- Adds "note" field to the PartRelated API endpoint
|
||||
|
||||
v282 - 2024-11-19 : https://github.com/inventree/InvenTree/pull/8487
|
||||
- Remove the "test statistics" API endpoints
|
||||
- This is now provided via a custom plugin
|
||||
|
||||
v281 - 2024-11-15 : https://github.com/inventree/InvenTree/pull/8480
|
||||
- Fixes StockHistory API data serialization
|
||||
|
||||
v280 - 2024-11-10 : https://github.com/inventree/InvenTree/pull/8461
|
||||
- Makes schema for API information endpoint more informing
|
||||
- Removes general not found endpoint
|
||||
|
||||
@@ -4,6 +4,7 @@ import sys
|
||||
from decimal import Decimal
|
||||
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -152,7 +153,10 @@ class DatePickerFormField(forms.DateField):
|
||||
def round_decimal(value, places, normalize=False):
|
||||
"""Round value to the specified number of places."""
|
||||
if type(value) in [Decimal, float]:
|
||||
value = round(value, places)
|
||||
try:
|
||||
value = round(value, places)
|
||||
except Exception:
|
||||
raise ValidationError(_('Invalid decimal value') + f' ({value})')
|
||||
|
||||
if normalize:
|
||||
# Remove any trailing zeroes
|
||||
|
||||
@@ -62,7 +62,7 @@ class ClassValidationMixin:
|
||||
|
||||
if len(missing_attributes) > 0:
|
||||
errors.append(
|
||||
f"did not provide the following attributes: {', '.join(missing_attributes)}"
|
||||
f'did not provide the following attributes: {", ".join(missing_attributes)}'
|
||||
)
|
||||
if len(missing_overrides) > 0:
|
||||
missing_overrides_list = []
|
||||
@@ -75,7 +75,7 @@ class ClassValidationMixin:
|
||||
else:
|
||||
missing_overrides_list.append(base_implementation.__name__)
|
||||
errors.append(
|
||||
f"did not override the required attributes: {', '.join(missing_overrides_list)}"
|
||||
f'did not override the required attributes: {", ".join(missing_overrides_list)}'
|
||||
)
|
||||
|
||||
if len(errors) > 0:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import io
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from django.conf import settings
|
||||
@@ -179,12 +180,12 @@ def download_image_from_url(remote_url, timeout=2.5):
|
||||
|
||||
|
||||
def render_currency(
|
||||
money,
|
||||
decimal_places=None,
|
||||
currency=None,
|
||||
min_decimal_places=None,
|
||||
max_decimal_places=None,
|
||||
include_symbol=True,
|
||||
money: Money,
|
||||
decimal_places: Optional[int] = None,
|
||||
currency: Optional[str] = None,
|
||||
min_decimal_places: Optional[int] = None,
|
||||
max_decimal_places: Optional[int] = None,
|
||||
include_symbol: bool = True,
|
||||
):
|
||||
"""Render a currency / Money object to a formatted string (e.g. for reports).
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from django.core.validators import URLValidator
|
||||
from django.http import Http404
|
||||
|
||||
import pytz
|
||||
import structlog
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from InvenTree.cache import get_cache_config, is_global_cache_enabled
|
||||
@@ -85,36 +86,92 @@ ENABLE_CLASSIC_FRONTEND = get_boolean_setting(
|
||||
# Disable CUI parts if CUI tests are disabled
|
||||
if TESTING and '--exclude-tag=cui' in sys.argv:
|
||||
ENABLE_CLASSIC_FRONTEND = False
|
||||
|
||||
ENABLE_PLATFORM_FRONTEND = get_boolean_setting(
|
||||
'INVENTREE_PLATFORM_FRONTEND', 'platform_frontend', True
|
||||
)
|
||||
|
||||
# Configure logging settings
|
||||
log_level = get_setting('INVENTREE_LOG_LEVEL', 'log_level', 'WARNING')
|
||||
LOG_LEVEL = get_setting('INVENTREE_LOG_LEVEL', 'log_level', 'WARNING')
|
||||
JSON_LOG = get_boolean_setting('INVENTREE_JSON_LOG', 'json_log', False)
|
||||
WRITE_LOG = get_boolean_setting('INVENTREE_WRITE_LOG', 'write_log', False)
|
||||
|
||||
logging.basicConfig(level=log_level, format='%(asctime)s %(levelname)s %(message)s')
|
||||
|
||||
if log_level not in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']:
|
||||
log_level = 'WARNING' # pragma: no cover
|
||||
logging.basicConfig(level=LOG_LEVEL, format='%(asctime)s %(levelname)s %(message)s')
|
||||
|
||||
if LOG_LEVEL not in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']:
|
||||
LOG_LEVEL = 'WARNING' # pragma: no cover
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'handlers': {'console': {'class': 'logging.StreamHandler'}},
|
||||
'root': {'handlers': ['console'], 'level': log_level},
|
||||
'filters': {
|
||||
'require_not_maintenance_mode_503': {
|
||||
'()': 'maintenance_mode.logging.RequireNotMaintenanceMode503'
|
||||
}
|
||||
},
|
||||
'formatters': {
|
||||
'json_formatter': {
|
||||
'()': structlog.stdlib.ProcessorFormatter,
|
||||
'processor': structlog.processors.JSONRenderer(),
|
||||
},
|
||||
'plain_console': {
|
||||
'()': structlog.stdlib.ProcessorFormatter,
|
||||
'processor': structlog.dev.ConsoleRenderer(),
|
||||
},
|
||||
'key_value': {
|
||||
'()': structlog.stdlib.ProcessorFormatter,
|
||||
'processor': structlog.processors.KeyValueRenderer(
|
||||
key_order=['timestamp', 'level', 'event', 'logger']
|
||||
),
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'console': {'class': 'logging.StreamHandler', 'formatter': 'plain_console'}
|
||||
},
|
||||
'loggers': {
|
||||
'django_structlog': {'handlers': ['console'], 'level': LOG_LEVEL},
|
||||
'inventree': {'handlers': ['console'], 'level': LOG_LEVEL},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Add handlers
|
||||
if WRITE_LOG and JSON_LOG: # pragma: no cover
|
||||
LOGGING['handlers']['log_file'] = {
|
||||
'class': 'logging.handlers.WatchedFileHandler',
|
||||
'filename': str(BASE_DIR.joinpath('logs.json')),
|
||||
'formatter': 'json_formatter',
|
||||
}
|
||||
LOGGING['loggers']['django_structlog']['handlers'] += ['log_file']
|
||||
elif WRITE_LOG: # pragma: no cover
|
||||
LOGGING['handlers']['log_file'] = {
|
||||
'class': 'logging.handlers.WatchedFileHandler',
|
||||
'filename': str(BASE_DIR.joinpath('logs.log')),
|
||||
'formatter': 'key_value',
|
||||
}
|
||||
LOGGING['loggers']['django_structlog']['handlers'] += ['log_file']
|
||||
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.stdlib.filter_by_level,
|
||||
structlog.processors.TimeStamper(fmt='iso'),
|
||||
structlog.stdlib.add_logger_name,
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.format_exc_info,
|
||||
structlog.processors.UnicodeDecoder(),
|
||||
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
||||
],
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
# Optionally add database-level logging
|
||||
if get_setting('INVENTREE_DB_LOGGING', 'db_logging', False):
|
||||
LOGGING['loggers'] = {'django.db.backends': {'level': log_level or 'DEBUG'}}
|
||||
LOGGING['loggers'] = {'django.db.backends': {'level': LOG_LEVEL or 'DEBUG'}}
|
||||
|
||||
# Get a logger instance for this setup file
|
||||
logger = logging.getLogger('inventree')
|
||||
logger = structlog.getLogger('inventree')
|
||||
|
||||
# Load SECRET_KEY
|
||||
SECRET_KEY = config.get_secret_key()
|
||||
@@ -160,7 +217,8 @@ PLUGIN_RETRY = get_setting(
|
||||
'INVENTREE_PLUGIN_RETRY', 'PLUGIN_RETRY', 3, typecast=int
|
||||
) # How often should plugin loading be tried?
|
||||
|
||||
PLUGIN_FILE_CHECKED = False # Was the plugin file checked?
|
||||
# Hash of the plugin file (will be updated on each change)
|
||||
PLUGIN_FILE_HASH = ''
|
||||
|
||||
STATICFILES_DIRS = []
|
||||
|
||||
@@ -264,6 +322,7 @@ INSTALLED_APPS = [
|
||||
'dbbackup', # Backups - django-dbbackup
|
||||
'taggit', # Tagging
|
||||
'flags', # Flagging - django-flags
|
||||
'django_structlog', # Structured logging
|
||||
'allauth', # Base app for SSO
|
||||
'allauth.account', # Extend user with accounts
|
||||
'allauth.socialaccount', # Use 'social' providers
|
||||
@@ -299,6 +358,7 @@ MIDDLEWARE = CONFIG.get(
|
||||
'InvenTree.middleware.Check2FAMiddleware', # Check if the user should be forced to use MFA
|
||||
'maintenance_mode.middleware.MaintenanceModeMiddleware',
|
||||
'InvenTree.middleware.InvenTreeExceptionProcessor', # Error reporting
|
||||
'django_structlog.middlewares.RequestMiddleware', # Structured logging
|
||||
],
|
||||
)
|
||||
|
||||
@@ -360,8 +420,8 @@ AUTHENTICATION_BACKENDS = CONFIG.get(
|
||||
# LDAP support
|
||||
LDAP_AUTH = get_boolean_setting('INVENTREE_LDAP_ENABLED', 'ldap.enabled', False)
|
||||
if LDAP_AUTH:
|
||||
import django_auth_ldap.config
|
||||
import ldap
|
||||
from django_auth_ldap.config import GroupOfUniqueNamesType, LDAPSearch
|
||||
|
||||
AUTHENTICATION_BACKENDS.append('django_auth_ldap.backend.LDAPBackend')
|
||||
|
||||
@@ -412,7 +472,7 @@ if LDAP_AUTH:
|
||||
AUTH_LDAP_BIND_PASSWORD = get_setting(
|
||||
'INVENTREE_LDAP_BIND_PASSWORD', 'ldap.bind_password'
|
||||
)
|
||||
AUTH_LDAP_USER_SEARCH = LDAPSearch(
|
||||
AUTH_LDAP_USER_SEARCH = django_auth_ldap.config.LDAPSearch(
|
||||
get_setting('INVENTREE_LDAP_SEARCH_BASE_DN', 'ldap.search_base_dn'),
|
||||
ldap.SCOPE_SUBTREE,
|
||||
str(
|
||||
@@ -439,12 +499,38 @@ if LDAP_AUTH:
|
||||
'INVENTREE_LDAP_CACHE_TIMEOUT', 'ldap.cache_timeout', 3600, int
|
||||
)
|
||||
|
||||
AUTH_LDAP_GROUP_SEARCH = LDAPSearch(
|
||||
AUTH_LDAP_MIRROR_GROUPS = get_boolean_setting(
|
||||
'INVENTREE_LDAP_MIRROR_GROUPS', 'ldap.mirror_groups', False
|
||||
)
|
||||
AUTH_LDAP_GROUP_OBJECT_CLASS = get_setting(
|
||||
'INVENTREE_LDAP_GROUP_OBJECT_CLASS',
|
||||
'ldap.group_object_class',
|
||||
'groupOfUniqueNames',
|
||||
str,
|
||||
)
|
||||
AUTH_LDAP_GROUP_SEARCH = django_auth_ldap.config.LDAPSearch(
|
||||
get_setting('INVENTREE_LDAP_GROUP_SEARCH', 'ldap.group_search'),
|
||||
ldap.SCOPE_SUBTREE,
|
||||
'(objectClass=groupOfUniqueNames)',
|
||||
f'(objectClass={AUTH_LDAP_GROUP_OBJECT_CLASS})',
|
||||
)
|
||||
AUTH_LDAP_GROUP_TYPE_CLASS = get_setting(
|
||||
'INVENTREE_LDAP_GROUP_TYPE_CLASS',
|
||||
'ldap.group_type_class',
|
||||
'GroupOfUniqueNamesType',
|
||||
str,
|
||||
)
|
||||
AUTH_LDAP_GROUP_TYPE_CLASS_ARGS = get_setting(
|
||||
'INVENTREE_LDAP_GROUP_TYPE_CLASS_ARGS', 'ldap.group_type_class_args', [], list
|
||||
)
|
||||
AUTH_LDAP_GROUP_TYPE_CLASS_KWARGS = get_setting(
|
||||
'INVENTREE_LDAP_GROUP_TYPE_CLASS_KWARGS',
|
||||
'ldap.group_type_class_kwargs',
|
||||
{'name_attr': 'cn'},
|
||||
dict,
|
||||
)
|
||||
AUTH_LDAP_GROUP_TYPE = getattr(django_auth_ldap.config, AUTH_LDAP_GROUP_TYPE_CLASS)(
|
||||
*AUTH_LDAP_GROUP_TYPE_CLASS_ARGS, **AUTH_LDAP_GROUP_TYPE_CLASS_KWARGS
|
||||
)
|
||||
AUTH_LDAP_GROUP_TYPE = GroupOfUniqueNamesType(name_attr='cn')
|
||||
AUTH_LDAP_REQUIRE_GROUP = get_setting(
|
||||
'INVENTREE_LDAP_REQUIRE_GROUP', 'ldap.require_group'
|
||||
)
|
||||
|
||||
@@ -1112,8 +1112,3 @@ a {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.test-statistics-table-total-row {
|
||||
font-weight: bold;
|
||||
border-top-style: double;
|
||||
}
|
||||
|
||||
@@ -667,3 +667,15 @@ def admin_url(user, table, pk):
|
||||
pass
|
||||
|
||||
return url
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def cui_enabled():
|
||||
"""Return True if the CUI is enabled."""
|
||||
return settings.ENABLE_CLASSIC_FRONTEND
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def pui_enabled():
|
||||
"""Return True if the PUI is enabled."""
|
||||
return settings.ENABLE_PLATFORM_FRONTEND
|
||||
|
||||
@@ -1034,14 +1034,14 @@ class TestVersionNumber(TestCase):
|
||||
# Check that the current .git values work too
|
||||
|
||||
git_hash = str(
|
||||
subprocess.check_output('git rev-parse --short HEAD'.split()), 'utf-8'
|
||||
subprocess.check_output(['git', 'rev-parse', '--short', 'HEAD']), 'utf-8'
|
||||
).strip()
|
||||
|
||||
# On some systems the hash is a different length, so just check the first 6 characters
|
||||
self.assertEqual(git_hash[:6], version.inventreeCommitHash()[:6])
|
||||
|
||||
d = (
|
||||
str(subprocess.check_output('git show -s --format=%ci'.split()), 'utf-8')
|
||||
str(subprocess.check_output(['git', 'show', '-s', '--format=%ci']), 'utf-8')
|
||||
.strip()
|
||||
.split(' ')[0]
|
||||
)
|
||||
@@ -1184,18 +1184,8 @@ class TestSettings(InvenTreeTestCase):
|
||||
"""Test if install of plugins on startup works."""
|
||||
from plugin import registry
|
||||
|
||||
if not settings.DOCKER:
|
||||
# Check an install run
|
||||
response = registry.install_plugin_file()
|
||||
self.assertEqual(response, 'first_run')
|
||||
|
||||
# Set dynamic setting to True and rerun to launch install
|
||||
InvenTreeSetting.set_setting('PLUGIN_ON_STARTUP', True, self.user)
|
||||
registry.reload_plugins(full_reload=True)
|
||||
|
||||
# Check that there was another run
|
||||
response = registry.install_plugin_file()
|
||||
self.assertEqual(response, True)
|
||||
registry.reload_plugins(full_reload=True, collect=True)
|
||||
self.assertGreater(len(settings.PLUGIN_FILE_HASH), 0)
|
||||
|
||||
def test_helpers_cfg_file(self):
|
||||
"""Test get_config_file."""
|
||||
|
||||
@@ -35,7 +35,6 @@ from company.urls import company_urls, manufacturer_part_urls, supplier_part_url
|
||||
from order.urls import order_urls
|
||||
from part.urls import part_urls
|
||||
from plugin.urls import get_plugin_urls
|
||||
from stock.api import test_statistics_api_urls
|
||||
from stock.urls import stock_urls
|
||||
from web.urls import api_urls as web_api_urls
|
||||
from web.urls import urlpatterns as platform_urls
|
||||
@@ -110,7 +109,6 @@ apipatterns = [
|
||||
),
|
||||
]),
|
||||
),
|
||||
path('test-statistics/', include(test_statistics_api_urls)),
|
||||
path('user/', include(users.api.user_urls)),
|
||||
path('web/', include(web_api_urls)),
|
||||
# Plugin endpoints
|
||||
@@ -506,14 +504,14 @@ urlpatterns.append(
|
||||
)
|
||||
)
|
||||
|
||||
# Send any unknown URLs to the parts page
|
||||
# Send any unknown URLs to the index page
|
||||
urlpatterns += [
|
||||
re_path(
|
||||
r'^.*$',
|
||||
RedirectView.as_view(
|
||||
url='/index/'
|
||||
if settings.ENABLE_CLASSIC_FRONTEND
|
||||
else settings.FRONTEND_URL_BASE,
|
||||
else f'/{settings.FRONTEND_URL_BASE}/',
|
||||
permanent=False,
|
||||
),
|
||||
name='index',
|
||||
|
||||
@@ -333,11 +333,6 @@ src="{% static 'img/blank_image.png' %}"
|
||||
});
|
||||
});
|
||||
|
||||
{% if build.part.testable %}
|
||||
onPanelLoad("test-statistics", function() {
|
||||
prepareTestStatisticsTable('build', '{% url "api-test-statistics-by-build" build.pk %}')
|
||||
});
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -267,21 +267,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='panel panel-hidden' id='panel-test-statistics'>
|
||||
<div class='panel-heading'>
|
||||
<h4>
|
||||
{% trans "Build test statistics" %}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div class='panel-content'>
|
||||
<div id='teststatistics-button-toolbar'>
|
||||
{% include "filter_list.html" with id="buildteststatistics" %}
|
||||
</div>
|
||||
{% include "test_statistics_table.html" with prefix="build-" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='panel panel-hidden' id='panel-attachments'>
|
||||
<div class='panel-heading'>
|
||||
<div class='d-flex flex-wrap'>
|
||||
|
||||
@@ -21,8 +21,6 @@
|
||||
{% trans "Child Build Orders" as text %}
|
||||
{% include "sidebar_item.html" with label='children' text=text icon="fa-sitemap" %}
|
||||
{% if build.part.testable %}
|
||||
{% trans "Test Statistics" as text %}
|
||||
{% include "sidebar_item.html" with label='test-statistics' text=text icon="fa-chart-line" %}
|
||||
{% endif %}
|
||||
{% trans "Attachments" as text %}
|
||||
{% include "sidebar_item.html" with label='attachments' text=text icon="fa-paperclip" %}
|
||||
|
||||
@@ -42,8 +42,13 @@ debug_shell: False
|
||||
# Options: DEBUG / INFO / WARNING / ERROR / CRITICAL
|
||||
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
|
||||
|
||||
# Select default system language , or use the environment variable INVENTREE_LANGUAGE
|
||||
language: en-us
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -260,7 +260,7 @@ class BaseMachineType(
|
||||
error_parts.append(
|
||||
f'{config_type.name} settings: ' + ', '.join(missing)
|
||||
)
|
||||
self.handle_error(f"Missing {' and '.join(error_parts)}")
|
||||
self.handle_error(f'Missing {" and ".join(error_parts)}')
|
||||
return
|
||||
|
||||
try:
|
||||
|
||||
@@ -112,7 +112,7 @@ class MachineConfig(models.Model):
|
||||
"""Get machine errors for django admin interface."""
|
||||
return format_html_join(
|
||||
mark_safe('<br>'), '{}', ((str(error),) for error in self.errors)
|
||||
) or mark_safe(f"<i>{_('No errors')}</i>")
|
||||
) or mark_safe(f'<i>{_("No errors")}</i>')
|
||||
|
||||
@admin.display(description=_('Machine status'))
|
||||
def get_machine_status(self):
|
||||
|
||||
@@ -470,7 +470,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
||||
|
||||
def __str__(self):
|
||||
"""Render a string representation of this PurchaseOrder."""
|
||||
return f"{self.reference} - {self.supplier.name if self.supplier else _('deleted')}"
|
||||
return f'{self.reference} - {self.supplier.name if self.supplier else _("deleted")}'
|
||||
|
||||
reference = models.CharField(
|
||||
unique=True,
|
||||
@@ -996,7 +996,7 @@ class SalesOrder(TotalPriceMixin, Order):
|
||||
|
||||
def __str__(self):
|
||||
"""Render a string representation of this SalesOrder."""
|
||||
return f"{self.reference} - {self.customer.name if self.customer else _('deleted')}"
|
||||
return f'{self.reference} - {self.customer.name if self.customer else _("deleted")}'
|
||||
|
||||
reference = models.CharField(
|
||||
unique=True,
|
||||
@@ -2194,7 +2194,7 @@ class ReturnOrder(TotalPriceMixin, Order):
|
||||
|
||||
def __str__(self):
|
||||
"""Render a string representation of this ReturnOrder."""
|
||||
return f"{self.reference} - {self.customer.name if self.customer else _('no customer')}"
|
||||
return f'{self.reference} - {self.customer.name if self.customer else _("no customer")}'
|
||||
|
||||
reference = models.CharField(
|
||||
unique=True,
|
||||
|
||||
@@ -1720,7 +1720,7 @@ class SalesOrderSerialAllocationSerializer(serializers.Serializer):
|
||||
|
||||
line_item = data['line_item']
|
||||
stock_items = data['stock_items']
|
||||
shipment = data['shipment']
|
||||
shipment = data.get('shipment', None)
|
||||
|
||||
allocations = []
|
||||
|
||||
@@ -1758,10 +1758,10 @@ class SalesOrderShipmentAllocationSerializer(serializers.Serializer):
|
||||
"""Run validation against the provided shipment instance."""
|
||||
order = self.context['order']
|
||||
|
||||
if shipment.shipment_date is not None:
|
||||
if shipment and shipment.shipment_date is not None:
|
||||
raise ValidationError(_('Shipment has already been shipped'))
|
||||
|
||||
if shipment.order != order:
|
||||
if shipment and shipment.order != order:
|
||||
raise ValidationError(_('Shipment is not associated with this order'))
|
||||
|
||||
return shipment
|
||||
|
||||
@@ -1427,37 +1427,50 @@ class PartDetail(PartMixin, RetrieveUpdateDestroyAPI):
|
||||
return response
|
||||
|
||||
|
||||
class PartRelatedList(ListCreateAPI):
|
||||
"""API endpoint for accessing a list of PartRelated objects."""
|
||||
class PartRelatedFilter(rest_filters.FilterSet):
|
||||
"""FilterSet for PartRelated objects."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = PartRelated
|
||||
fields = ['part_1', 'part_2']
|
||||
|
||||
part = rest_filters.ModelChoiceFilter(
|
||||
queryset=Part.objects.all(), method='filter_part', label=_('Part')
|
||||
)
|
||||
|
||||
def filter_part(self, queryset, name, part):
|
||||
"""Filter queryset to include only PartRelated objects which reference the specified part."""
|
||||
return queryset.filter(Q(part_1=part) | Q(part_2=part)).distinct()
|
||||
|
||||
|
||||
class PartRelatedMixin:
|
||||
"""Mixin class for PartRelated API endpoints."""
|
||||
|
||||
queryset = PartRelated.objects.all()
|
||||
serializer_class = part_serializers.PartRelationSerializer
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""Custom queryset filtering."""
|
||||
queryset = super().filter_queryset(queryset)
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
"""Return an annotated queryset for the PartRelatedDetail endpoint."""
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
|
||||
params = self.request.query_params
|
||||
|
||||
# Add a filter for "part" - we can filter either part_1 or part_2
|
||||
part = params.get('part', None)
|
||||
|
||||
if part is not None:
|
||||
try:
|
||||
part = Part.objects.get(pk=part)
|
||||
queryset = queryset.filter(Q(part_1=part) | Q(part_2=part)).distinct()
|
||||
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
pass
|
||||
queryset = queryset.prefetch_related('part_1', 'part_2')
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class PartRelatedDetail(RetrieveUpdateDestroyAPI):
|
||||
"""API endpoint for accessing detail view of a PartRelated object."""
|
||||
class PartRelatedList(PartRelatedMixin, ListCreateAPI):
|
||||
"""API endpoint for accessing a list of PartRelated objects."""
|
||||
|
||||
queryset = PartRelated.objects.all()
|
||||
serializer_class = part_serializers.PartRelationSerializer
|
||||
filterset_class = PartRelatedFilter
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
search_fields = ['part_1__name', 'part_2__name']
|
||||
|
||||
|
||||
class PartRelatedDetail(PartRelatedMixin, RetrieveUpdateDestroyAPI):
|
||||
"""API endpoint for accessing detail view of a PartRelated object."""
|
||||
|
||||
|
||||
class PartParameterTemplateFilter(rest_filters.FilterSet):
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.16 on 2024-11-19 12:50
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('part', '0130_alter_parttesttemplate_part'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='partrelated',
|
||||
name='note',
|
||||
field=models.CharField(blank=True, help_text='Note for this relationship', max_length=500, verbose_name='Note'),
|
||||
),
|
||||
]
|
||||
@@ -4680,6 +4680,13 @@ class PartRelated(InvenTree.models.InvenTreeMetadataModel):
|
||||
help_text=_('Select Related Part'),
|
||||
)
|
||||
|
||||
note = models.CharField(
|
||||
max_length=500,
|
||||
blank=True,
|
||||
verbose_name=_('Note'),
|
||||
help_text=_('Note for this relationship'),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
"""Return a string representation of this Part-Part relationship."""
|
||||
return f'{self.part_1} <--> {self.part_2}'
|
||||
|
||||
@@ -1505,7 +1505,7 @@ class PartRelationSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
"""Metaclass defining serializer fields."""
|
||||
|
||||
model = PartRelated
|
||||
fields = ['pk', 'part_1', 'part_1_detail', 'part_2', 'part_2_detail']
|
||||
fields = ['pk', 'part_1', 'part_1_detail', 'part_2', 'part_2_detail', 'note']
|
||||
|
||||
part_1_detail = PartSerializer(source='part_1', read_only=True, many=False)
|
||||
part_2_detail = PartSerializer(source='part_2', read_only=True, many=False)
|
||||
|
||||
@@ -100,22 +100,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='panel panel-hidden' id='panel-test-statistics'>
|
||||
<div class='panel-heading'>
|
||||
<div class='d-flex flex-wrap'>
|
||||
<h4>{% trans "Part Test Statistics" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
</div>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<div id='teststatistics-button-toolbar'>
|
||||
{% include "filter_list.html" with id="partteststatistics" %}
|
||||
</div>
|
||||
|
||||
{% include "test_statistics_table.html" with prefix="part-" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='panel panel-hidden' id='panel-purchase-orders'>
|
||||
<div class='panel-heading'>
|
||||
<div class='d-flex flex-wrap'>
|
||||
@@ -767,9 +751,6 @@
|
||||
});
|
||||
});
|
||||
});
|
||||
onPanelLoad("test-statistics", function() {
|
||||
prepareTestStatisticsTable('part', '{% url "api-test-statistics-by-part" part.pk %}')
|
||||
});
|
||||
|
||||
onPanelLoad("part-stock", function() {
|
||||
$('#new-stock-item').click(function () {
|
||||
|
||||
@@ -53,8 +53,6 @@
|
||||
{% if part.testable %}
|
||||
{% trans "Test Templates" as text %}
|
||||
{% include "sidebar_item.html" with label="test-templates" text=text icon="fa-vial" %}
|
||||
{% trans "Test Statistics" as text %}
|
||||
{% include "sidebar_item.html" with label="test-statistics" text=text icon="fa-chart-line" %}
|
||||
{% endif %}
|
||||
{% if show_related %}
|
||||
{% trans "Related Parts" as text %}
|
||||
|
||||
@@ -295,7 +295,7 @@ class PartImport(FileManagementFormView):
|
||||
|
||||
# Set alerts
|
||||
if import_done:
|
||||
alert = f"<strong>{_('Part-Import')}</strong><br>{_(f'Imported {import_done} parts')}"
|
||||
alert = f'<strong>{_("Part-Import")}</strong><br>{_(f"Imported {import_done} parts")}'
|
||||
messages.success(self.request, alert)
|
||||
if import_error:
|
||||
error_text = '\n'.join([
|
||||
@@ -304,7 +304,7 @@ class PartImport(FileManagementFormView):
|
||||
])
|
||||
messages.error(
|
||||
self.request,
|
||||
f"<strong>{_('Some errors occurred:')}</strong><br><ul>{error_text}</ul>",
|
||||
f'<strong>{_("Some errors occurred:")}</strong><br><ul>{error_text}</ul>',
|
||||
)
|
||||
|
||||
return HttpResponseRedirect(reverse('part-index'))
|
||||
|
||||
@@ -23,8 +23,6 @@ class PluginAppConfig(AppConfig):
|
||||
|
||||
def ready(self):
|
||||
"""The ready method is extended to initialize plugins."""
|
||||
# skip loading if we run in a background thread
|
||||
|
||||
if not isInMainThread() and not isInWorkerThread():
|
||||
return
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from rest_framework.generics import GenericAPIView
|
||||
from rest_framework.response import Response
|
||||
|
||||
from InvenTree.tasks import offload_task
|
||||
from plugin.registry import registry
|
||||
from plugin.registry import call_plugin_function, registry
|
||||
from stock.models import StockItem, StockLocation
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ class LocatePluginView(GenericAPIView):
|
||||
StockItem.objects.get(pk=item_pk)
|
||||
|
||||
offload_task(
|
||||
registry.call_plugin_function,
|
||||
call_plugin_function,
|
||||
plugin,
|
||||
'locate_stock_item',
|
||||
item_pk,
|
||||
@@ -78,7 +78,7 @@ class LocatePluginView(GenericAPIView):
|
||||
StockLocation.objects.get(pk=location_pk)
|
||||
|
||||
offload_task(
|
||||
registry.call_plugin_function,
|
||||
call_plugin_function,
|
||||
plugin,
|
||||
'locate_stock_location',
|
||||
location_pk,
|
||||
|
||||
@@ -177,7 +177,17 @@ def get_modules(pkg, path=None):
|
||||
elif type(path) is not list:
|
||||
path = [path]
|
||||
|
||||
for finder, name, _ in pkgutil.walk_packages(path):
|
||||
packages = pkgutil.walk_packages(path)
|
||||
|
||||
while True:
|
||||
try:
|
||||
finder, name, _ = next(packages)
|
||||
except StopIteration:
|
||||
break
|
||||
except Exception as error:
|
||||
log_error({pkg.__name__: str(error)}, 'discovery')
|
||||
continue
|
||||
|
||||
try:
|
||||
if sys.version_info < (3, 12):
|
||||
module = finder.find_module(name).load_module(name)
|
||||
@@ -202,9 +212,13 @@ def get_modules(pkg, path=None):
|
||||
return [v for k, v in context.items()]
|
||||
|
||||
|
||||
def get_classes(module):
|
||||
def get_classes(module) -> list:
|
||||
"""Get all classes in a given module."""
|
||||
return inspect.getmembers(module, inspect.isclass)
|
||||
try:
|
||||
return inspect.getmembers(module, inspect.isclass)
|
||||
except Exception:
|
||||
log_error({module.__name__: 'Could not get classes'}, 'discovery')
|
||||
return []
|
||||
|
||||
|
||||
def get_plugins(pkg, baseclass, path=None):
|
||||
|
||||
@@ -19,12 +19,15 @@ logger = logging.getLogger('inventree')
|
||||
def pip_command(*args):
|
||||
"""Build and run a pip command using using the current python executable.
|
||||
|
||||
returns: subprocess.check_output
|
||||
throws: subprocess.CalledProcessError
|
||||
Returns: The output of the pip command
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: If the pip command fails
|
||||
"""
|
||||
python = sys.executable
|
||||
|
||||
command = [python, '-m', 'pip']
|
||||
|
||||
command.extend(args)
|
||||
|
||||
command = [str(x) for x in command]
|
||||
@@ -63,39 +66,55 @@ def handle_pip_error(error, path: str) -> list:
|
||||
raise ValidationError(errors[0])
|
||||
|
||||
|
||||
def check_package_path(packagename: str):
|
||||
"""Determine the install path of a particular package.
|
||||
def get_install_info(packagename: str) -> dict:
|
||||
"""Determine the install information for a particular package.
|
||||
|
||||
- If installed, return the installation path
|
||||
- If not installed, return False
|
||||
- Uses 'pip show' to determine the install location of a package.
|
||||
"""
|
||||
logger.debug('check_package_path: %s', packagename)
|
||||
logger.debug('get_install_info: %s', packagename)
|
||||
|
||||
# Remove version information
|
||||
for c in '<>=! ':
|
||||
for c in '<>=!@ ':
|
||||
packagename = packagename.split(c)[0]
|
||||
|
||||
info = {}
|
||||
|
||||
try:
|
||||
result = pip_command('show', packagename)
|
||||
|
||||
output = result.decode('utf-8').split('\n')
|
||||
|
||||
for line in output:
|
||||
# Check if line matches pattern "Location: ..."
|
||||
match = re.match(r'^Location:\s+(.+)$', line.strip())
|
||||
parts = line.split(':')
|
||||
|
||||
if match:
|
||||
return match.group(1)
|
||||
if len(parts) >= 2:
|
||||
key = str(parts[0].strip().lower().replace('-', '_'))
|
||||
value = str(parts[1].strip())
|
||||
|
||||
info[key] = value
|
||||
|
||||
except subprocess.CalledProcessError as error:
|
||||
log_error('check_package_path')
|
||||
log_error('get_install_info')
|
||||
|
||||
output = error.output.decode('utf-8')
|
||||
info['error'] = output
|
||||
logger.exception('Plugin lookup failed: %s', str(output))
|
||||
return False
|
||||
|
||||
# If we get here, the package is not installed
|
||||
return False
|
||||
return info
|
||||
|
||||
|
||||
def plugins_file_hash():
|
||||
"""Return the file hash for the plugins file."""
|
||||
import hashlib
|
||||
|
||||
pf = settings.PLUGIN_FILE
|
||||
|
||||
if not pf or not pf.exists():
|
||||
return None
|
||||
|
||||
with pf.open('rb') as f:
|
||||
# Note: Once we support 3.11 as a minimum, we can use hashlib.file_digest
|
||||
return hashlib.sha256(f.read()).hexdigest()
|
||||
|
||||
|
||||
def install_plugins_file():
|
||||
@@ -108,8 +127,10 @@ def install_plugins_file():
|
||||
logger.warning('Plugin file %s does not exist', str(pf))
|
||||
return
|
||||
|
||||
cmd = ['install', '--disable-pip-version-check', '-U', '-r', str(pf)]
|
||||
|
||||
try:
|
||||
pip_command('install', '-r', str(pf))
|
||||
pip_command(*cmd)
|
||||
except subprocess.CalledProcessError as error:
|
||||
output = error.output.decode('utf-8')
|
||||
logger.exception('Plugin file installation failed: %s', str(output))
|
||||
@@ -120,17 +141,25 @@ def install_plugins_file():
|
||||
log_error('pip')
|
||||
return False
|
||||
|
||||
# Update static files
|
||||
# Collect plugin static files
|
||||
plugin.staticfiles.collect_plugins_static_files()
|
||||
plugin.staticfiles.clear_plugins_static_files()
|
||||
|
||||
# At this point, the plugins file has been installed
|
||||
return True
|
||||
|
||||
|
||||
def update_plugins_file(install_name, remove=False):
|
||||
def update_plugins_file(install_name, full_package=None, version=None, remove=False):
|
||||
"""Add a plugin to the plugins file."""
|
||||
logger.info('Adding plugin to plugins file: %s', install_name)
|
||||
if remove:
|
||||
logger.info('Removing plugin from plugins file: %s', install_name)
|
||||
else:
|
||||
logger.info('Adding plugin to plugins file: %s', install_name)
|
||||
|
||||
# If a full package name is provided, use that instead
|
||||
if full_package and full_package != install_name:
|
||||
new_value = full_package
|
||||
else:
|
||||
new_value = f'{install_name}=={version}' if version else install_name
|
||||
|
||||
pf = settings.PLUGIN_FILE
|
||||
|
||||
@@ -140,7 +169,7 @@ def update_plugins_file(install_name, remove=False):
|
||||
|
||||
def compare_line(line: str):
|
||||
"""Check if a line in the file matches the installname."""
|
||||
return line.strip().split('==')[0] == install_name.split('==')[0]
|
||||
return re.match(rf'^{install_name}[\s=@]', line.strip())
|
||||
|
||||
# First, read in existing plugin file
|
||||
try:
|
||||
@@ -166,13 +195,13 @@ def update_plugins_file(install_name, remove=False):
|
||||
found = True
|
||||
if not remove:
|
||||
# Replace line with new install name
|
||||
output.append(install_name)
|
||||
output.append(new_value)
|
||||
else:
|
||||
output.append(line)
|
||||
|
||||
# Append plugin to file
|
||||
if not found and not remove:
|
||||
output.append(install_name)
|
||||
output.append(new_value)
|
||||
|
||||
# Write file back to disk
|
||||
try:
|
||||
@@ -203,15 +232,8 @@ def install_plugin(url=None, packagename=None, user=None, version=None):
|
||||
|
||||
logger.info('install_plugin: %s, %s', url, packagename)
|
||||
|
||||
# Check if we are running in a virtual environment
|
||||
# For now, just log a warning
|
||||
in_venv = sys.prefix != sys.base_prefix
|
||||
|
||||
if not in_venv:
|
||||
logger.warning('InvenTree is not running in a virtual environment')
|
||||
|
||||
# build up the command
|
||||
install_name = ['install', '-U']
|
||||
install_name = ['install', '-U', '--disable-pip-version-check']
|
||||
|
||||
full_pkg = ''
|
||||
|
||||
@@ -246,23 +268,25 @@ def install_plugin(url=None, packagename=None, user=None, version=None):
|
||||
ret['result'] = ret['success'] = _('Installed plugin successfully')
|
||||
ret['output'] = str(result, 'utf-8')
|
||||
|
||||
if packagename and (path := check_package_path(packagename)):
|
||||
# Override result information
|
||||
ret['result'] = _(f'Installed plugin into {path}')
|
||||
if packagename and (info := get_install_info(packagename)):
|
||||
if path := info.get('location'):
|
||||
ret['result'] = _(f'Installed plugin into {path}')
|
||||
ret['version'] = info.get('version')
|
||||
|
||||
except subprocess.CalledProcessError as error:
|
||||
handle_pip_error(error, 'plugin_install')
|
||||
|
||||
# Save plugin to plugins file
|
||||
update_plugins_file(full_pkg)
|
||||
if version := ret.get('version'):
|
||||
# Save plugin to plugins file
|
||||
update_plugins_file(packagename, full_package=full_pkg, version=version)
|
||||
|
||||
# Reload the plugin registry, to discover the new plugin
|
||||
from plugin.registry import registry
|
||||
# Reload the plugin registry, to discover the new plugin
|
||||
from plugin.registry import registry
|
||||
|
||||
registry.reload_plugins(full_reload=True, force_reload=True, collect=True)
|
||||
registry.reload_plugins(full_reload=True, force_reload=True, collect=True)
|
||||
|
||||
# Update static files
|
||||
plugin.staticfiles.collect_plugins_static_files()
|
||||
# Update static files
|
||||
plugin.staticfiles.collect_plugins_static_files()
|
||||
|
||||
return ret
|
||||
|
||||
@@ -303,23 +327,24 @@ def uninstall_plugin(cfg: plugin.models.PluginConfig, user=None, delete_config=T
|
||||
_('Plugin cannot be uninstalled as it is currently active')
|
||||
)
|
||||
|
||||
if not cfg.is_installed():
|
||||
raise ValidationError(_('Plugin is not installed'))
|
||||
|
||||
validate_package_plugin(cfg, user)
|
||||
package_name = cfg.package_name
|
||||
logger.info('Uninstalling plugin: %s', package_name)
|
||||
|
||||
cmd = ['uninstall', '-y', package_name]
|
||||
pkg_info = get_install_info(package_name)
|
||||
|
||||
try:
|
||||
result = pip_command(*cmd)
|
||||
|
||||
ret = {
|
||||
'result': _('Uninstalled plugin successfully'),
|
||||
'success': True,
|
||||
'output': str(result, 'utf-8'),
|
||||
}
|
||||
|
||||
except subprocess.CalledProcessError as error:
|
||||
handle_pip_error(error, 'plugin_uninstall')
|
||||
if path := pkg_info.get('location'):
|
||||
# Uninstall the plugin using pip
|
||||
logger.info('Uninstalling plugin: %s from %s', package_name, path)
|
||||
try:
|
||||
pip_command('uninstall', '-y', package_name)
|
||||
except subprocess.CalledProcessError as error:
|
||||
handle_pip_error(error, 'plugin_uninstall')
|
||||
else:
|
||||
# No matching install target found
|
||||
raise ValidationError(_('Plugin installation not found'))
|
||||
|
||||
# Update the plugins file
|
||||
update_plugins_file(package_name, remove=True)
|
||||
@@ -334,4 +359,4 @@ def uninstall_plugin(cfg: plugin.models.PluginConfig, user=None, delete_config=T
|
||||
# Reload the plugin registry
|
||||
registry.reload_plugins(full_reload=True, force_reload=True, collect=True)
|
||||
|
||||
return ret
|
||||
return {'result': _('Uninstalled plugin successfully'), 'success': True}
|
||||
|
||||
@@ -70,7 +70,7 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model):
|
||||
"""Nice name for printing."""
|
||||
name = f'{self.name} - {self.key}'
|
||||
if not self.active:
|
||||
name += '(not active)'
|
||||
name += ' (not active)'
|
||||
return name
|
||||
|
||||
# extra attributes from the registry
|
||||
|
||||
@@ -239,9 +239,10 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase):
|
||||
"""File that contains plugin definition."""
|
||||
return Path(inspect.getfile(cls))
|
||||
|
||||
def path(self) -> Path:
|
||||
@classmethod
|
||||
def path(cls) -> Path:
|
||||
"""Path to plugins base folder."""
|
||||
return self.file().parent
|
||||
return cls.file().parent
|
||||
|
||||
def _get_value(self, meta_name: str, package_name: str) -> str:
|
||||
"""Extract values from class meta or package info.
|
||||
|
||||
@@ -159,7 +159,7 @@ class PluginsRegistry:
|
||||
# Update the registry hash value
|
||||
self.update_plugin_hash()
|
||||
|
||||
def call_plugin_function(self, slug, func, *args, **kwargs):
|
||||
def call_plugin_function(self, slug: str, func: str, *args, **kwargs):
|
||||
"""Call a member function (named by 'func') of the plugin named by 'slug'.
|
||||
|
||||
As this is intended to be run by the background worker,
|
||||
@@ -287,6 +287,7 @@ class PluginsRegistry:
|
||||
|
||||
if collect:
|
||||
logger.info('Collecting plugins')
|
||||
self.install_plugin_file()
|
||||
self.plugin_modules = self.collect_plugins()
|
||||
|
||||
self.plugins_loaded = False
|
||||
@@ -365,31 +366,32 @@ class PluginsRegistry:
|
||||
collected_plugins = []
|
||||
|
||||
# Collect plugins from paths
|
||||
for plugin in self.plugin_dirs():
|
||||
logger.debug("Loading plugins from directory '%s'", plugin)
|
||||
for plugin_dir in self.plugin_dirs():
|
||||
logger.debug("Loading plugins from directory '%s'", plugin_dir)
|
||||
|
||||
parent_path = None
|
||||
parent_obj = Path(plugin)
|
||||
parent_obj = Path(plugin_dir)
|
||||
|
||||
# If a "path" is provided, some special handling is required
|
||||
if parent_obj.name is not plugin and len(parent_obj.parts) > 1:
|
||||
if parent_obj.name is not plugin_dir and len(parent_obj.parts) > 1:
|
||||
# Ensure PosixPath object is converted to a string, before passing to get_plugins
|
||||
parent_path = str(parent_obj.parent)
|
||||
plugin = parent_obj.name
|
||||
plugin_dir = parent_obj.name
|
||||
|
||||
# Gather Modules
|
||||
if parent_path:
|
||||
# On python 3.12 use new loader method
|
||||
if sys.version_info < (3, 12):
|
||||
raw_module = _load_source(
|
||||
plugin, str(parent_obj.joinpath('__init__.py'))
|
||||
plugin_dir, str(parent_obj.joinpath('__init__.py'))
|
||||
)
|
||||
else:
|
||||
raw_module = SourceFileLoader(
|
||||
plugin, str(parent_obj.joinpath('__init__.py'))
|
||||
plugin_dir, str(parent_obj.joinpath('__init__.py'))
|
||||
).load_module()
|
||||
else:
|
||||
raw_module = importlib.import_module(plugin)
|
||||
raw_module = importlib.import_module(plugin_dir)
|
||||
|
||||
modules = get_plugins(raw_module, InvenTreePlugin, path=parent_path)
|
||||
|
||||
for item in modules or []:
|
||||
@@ -429,16 +431,13 @@ class PluginsRegistry:
|
||||
|
||||
def install_plugin_file(self):
|
||||
"""Make sure all plugins are installed in the current environment."""
|
||||
if settings.PLUGIN_FILE_CHECKED:
|
||||
logger.info('Plugin file was already checked')
|
||||
return True
|
||||
from plugin.installer import install_plugins_file, plugins_file_hash
|
||||
|
||||
from plugin.installer import install_plugins_file
|
||||
file_hash = plugins_file_hash()
|
||||
|
||||
if install_plugins_file():
|
||||
settings.PLUGIN_FILE_CHECKED = True
|
||||
return 'first_run'
|
||||
return False
|
||||
if file_hash != settings.PLUGIN_FILE_HASH:
|
||||
install_plugins_file()
|
||||
settings.PLUGIN_FILE_HASH = file_hash
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -807,7 +806,7 @@ class PluginsRegistry:
|
||||
registry: PluginsRegistry = PluginsRegistry()
|
||||
|
||||
|
||||
def call_function(plugin_name, function_name, *args, **kwargs):
|
||||
def call_plugin_function(plugin_name: str, function_name: str, *args, **kwargs):
|
||||
"""Global helper function to call a specific member function of a plugin."""
|
||||
return registry.call_plugin_function(plugin_name, function_name, *args, **kwargs)
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ class SampleLabelPrinter(LabelPrintingMixin, InvenTreePlugin):
|
||||
Normally here the connection to the printer and transfer of the label would take place.
|
||||
"""
|
||||
# Test that the expected kwargs are present
|
||||
print(f"Printing Label: {kwargs['filename']} (User: {kwargs['user']})")
|
||||
print(f'Printing Label: {kwargs["filename"]} (User: {kwargs["user"]})')
|
||||
|
||||
pdf_data = kwargs['pdf_data']
|
||||
png_file = self.render_to_png(
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.test import TestCase
|
||||
from plugin import InvenTreePlugin, registry
|
||||
from plugin.helpers import MixinImplementationError
|
||||
from plugin.mixins import ScheduleMixin
|
||||
from plugin.registry import call_function
|
||||
from plugin.registry import call_plugin_function
|
||||
|
||||
|
||||
class ExampleScheduledTaskPluginTests(TestCase):
|
||||
@@ -67,10 +67,10 @@ class ExampleScheduledTaskPluginTests(TestCase):
|
||||
def test_calling(self):
|
||||
"""Check if a function can be called without errors."""
|
||||
# Check with right parameters
|
||||
self.assertEqual(call_function('schedule', 'member_func'), False)
|
||||
self.assertEqual(call_plugin_function('schedule', 'member_func'), False)
|
||||
|
||||
# Check with wrong key
|
||||
self.assertEqual(call_function('does_not_exist', 'member_func'), None)
|
||||
self.assertEqual(call_plugin_function('does_not_exist', 'member_func'), None)
|
||||
|
||||
|
||||
class ScheduledTaskPluginTests(TestCase):
|
||||
|
||||
@@ -75,7 +75,7 @@ def copy_plugin_static_files(slug, check_reload=True):
|
||||
if not plugin:
|
||||
return
|
||||
|
||||
logger.info("Copying static files for plugin '%s'", slug)
|
||||
logger.info("Collecting static files for plugin '%s'", slug)
|
||||
|
||||
# Get the source path for the plugin
|
||||
source_path = plugin.path().joinpath('static')
|
||||
@@ -114,7 +114,8 @@ def copy_plugin_static_files(slug, check_reload=True):
|
||||
logger.debug('- copied %s to %s', str(item), str(destination_path))
|
||||
copied += 1
|
||||
|
||||
logger.info("Copied %s static files for plugin '%s'.", copied, slug)
|
||||
if copied > 0:
|
||||
logger.info("Copied %s static files for plugin '%s'.", copied, slug)
|
||||
|
||||
|
||||
def clear_plugin_static_files(slug: str, recursive: bool = True):
|
||||
|
||||
@@ -252,7 +252,7 @@ class RegistryTests(TestCase):
|
||||
def test_package_loading(self):
|
||||
"""Test that package distributed plugins work."""
|
||||
# Install sample package
|
||||
subprocess.check_output('pip install inventree-zapier'.split())
|
||||
subprocess.check_output(['pip', 'install', 'inventree-zapier'])
|
||||
|
||||
# Reload to discover plugin
|
||||
registry.reload_plugins(full_reload=True, collect=True)
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any, Optional
|
||||
|
||||
from django import template
|
||||
from django.conf import settings
|
||||
@@ -28,7 +30,7 @@ logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def getindex(container: list, index: int):
|
||||
def getindex(container: list, index: int) -> Any:
|
||||
"""Return the value contained at the specified index of the list.
|
||||
|
||||
This function is provideed to get around template rendering limitations.
|
||||
@@ -55,7 +57,7 @@ def getindex(container: list, index: int):
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def getkey(container: dict, key):
|
||||
def getkey(container: dict, key: str) -> Any:
|
||||
"""Perform key lookup in the provided dict object.
|
||||
|
||||
This function is provided to get around template rendering limitations.
|
||||
@@ -82,7 +84,7 @@ def asset(filename):
|
||||
filename: Asset filename (relative to the 'assets' media directory)
|
||||
|
||||
Raises:
|
||||
FileNotFoundError if file does not exist
|
||||
FileNotFoundError: If file does not exist
|
||||
"""
|
||||
if type(filename) is SafeString:
|
||||
# Prepend an empty string to enforce 'stringiness'
|
||||
@@ -104,30 +106,31 @@ def asset(filename):
|
||||
|
||||
@register.simple_tag()
|
||||
def uploaded_image(
|
||||
filename,
|
||||
replace_missing=True,
|
||||
replacement_file='blank_image.png',
|
||||
validate=True,
|
||||
filename: str,
|
||||
replace_missing: bool = True,
|
||||
replacement_file: str = 'blank_image.png',
|
||||
validate: bool = True,
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None,
|
||||
rotate: Optional[float] = None,
|
||||
**kwargs,
|
||||
):
|
||||
) -> str:
|
||||
"""Return raw image data from an 'uploaded' image.
|
||||
|
||||
Arguments:
|
||||
filename: The filename of the image relative to the MEDIA_ROOT directory
|
||||
replace_missing: Optionally return a placeholder image if the provided filename does not exist (default = True)
|
||||
replacement_file: The filename of the placeholder image (default = 'blank_image.png')
|
||||
validate: Optionally validate that the file is a valid image file (default = True)
|
||||
|
||||
kwargs:
|
||||
width: Optional width of the image (default = None)
|
||||
height: Optional height of the image (default = None)
|
||||
validate: Optionally validate that the file is a valid image file
|
||||
width: Optional width of the image
|
||||
height: Optional height of the image
|
||||
rotate: Optional rotation to apply to the image
|
||||
|
||||
Returns:
|
||||
Binary image data to be rendered directly in a <img> tag
|
||||
|
||||
Raises:
|
||||
FileNotFoundError if the file does not exist
|
||||
FileNotFoundError: If the file does not exist
|
||||
"""
|
||||
if type(filename) is SafeString:
|
||||
# Prepend an empty string to enforce 'stringiness'
|
||||
@@ -169,9 +172,6 @@ def uploaded_image(
|
||||
# A placeholder image showing that the image is missing
|
||||
img = Image.new('RGB', (64, 64), color='red')
|
||||
|
||||
width = kwargs.get('width')
|
||||
height = kwargs.get('height')
|
||||
|
||||
if width is not None:
|
||||
try:
|
||||
width = int(width)
|
||||
@@ -199,7 +199,7 @@ def uploaded_image(
|
||||
img = img.resize((wsize, height))
|
||||
|
||||
# Optionally rotate the image
|
||||
if rotate := kwargs.get('rotate'):
|
||||
if rotate is not None:
|
||||
try:
|
||||
rotate = int(rotate)
|
||||
img = img.rotate(rotate)
|
||||
@@ -213,7 +213,7 @@ def uploaded_image(
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def encode_svg_image(filename):
|
||||
def encode_svg_image(filename: str) -> str:
|
||||
"""Return a base64-encoded svg image data string."""
|
||||
if type(filename) is SafeString:
|
||||
# Prepend an empty string to enforce 'stringiness'
|
||||
@@ -243,7 +243,7 @@ def encode_svg_image(filename):
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def part_image(part: Part, preview=False, thumbnail=False, **kwargs):
|
||||
def part_image(part: Part, preview: bool = False, thumbnail: bool = False, **kwargs):
|
||||
"""Return a fully-qualified path for a part image.
|
||||
|
||||
Arguments:
|
||||
@@ -252,7 +252,7 @@ def part_image(part: Part, preview=False, thumbnail=False, **kwargs):
|
||||
thumbnail: Return the thumbnail image (default = False)
|
||||
|
||||
Raises:
|
||||
TypeError if provided part is not a Part instance
|
||||
TypeError: If provided part is not a Part instance
|
||||
"""
|
||||
if type(part) is not Part:
|
||||
raise TypeError(_('part_image tag requires a Part instance'))
|
||||
@@ -268,7 +268,7 @@ def part_image(part: Part, preview=False, thumbnail=False, **kwargs):
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def part_parameter(part: Part, parameter_name: str):
|
||||
def part_parameter(part: Part, parameter_name: str) -> str:
|
||||
"""Return a PartParameter object for the given part and parameter name.
|
||||
|
||||
Arguments:
|
||||
@@ -284,7 +284,9 @@ def part_parameter(part: Part, parameter_name: str):
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def company_image(company, preview=False, thumbnail=False, **kwargs):
|
||||
def company_image(
|
||||
company: Company, preview: bool = False, thumbnail: bool = False, **kwargs
|
||||
) -> str:
|
||||
"""Return a fully-qualified path for a company image.
|
||||
|
||||
Arguments:
|
||||
@@ -293,7 +295,7 @@ def company_image(company, preview=False, thumbnail=False, **kwargs):
|
||||
thumbnail: Return the thumbnail image (default = False)
|
||||
|
||||
Raises:
|
||||
TypeError if provided company is not a Company instance
|
||||
TypeError: If provided company is not a Company instance
|
||||
"""
|
||||
if type(company) is not Company:
|
||||
raise TypeError(_('company_image tag requires a Company instance'))
|
||||
@@ -309,7 +311,7 @@ def company_image(company, preview=False, thumbnail=False, **kwargs):
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def logo_image(**kwargs):
|
||||
def logo_image(**kwargs) -> str:
|
||||
"""Return a fully-qualified path for the logo image.
|
||||
|
||||
- If a custom logo has been provided, return a path to that logo
|
||||
@@ -322,7 +324,7 @@ def logo_image(**kwargs):
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def internal_link(link, text):
|
||||
def internal_link(link, text) -> str:
|
||||
"""Make a <a></a> href which points to an InvenTree URL.
|
||||
|
||||
Uses the InvenTree.helpers_model.construct_absolute_url function to build the URL.
|
||||
@@ -396,13 +398,20 @@ def render_html_text(text: str, **kwargs):
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def format_number(number, **kwargs):
|
||||
def format_number(
|
||||
number,
|
||||
decimal_places: Optional[int] = None,
|
||||
integer: bool = False,
|
||||
leading: int = 0,
|
||||
separator: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Render a number with optional formatting options.
|
||||
|
||||
kwargs:
|
||||
Arguments:
|
||||
decimal_places: Number of decimal places to render
|
||||
integer: Boolean, whether to render the number as an integer
|
||||
leading: Number of leading zeros
|
||||
leading: Number of leading zeros (default = 0)
|
||||
separator: Character to use as a thousands separator (default = None)
|
||||
"""
|
||||
try:
|
||||
number = Decimal(str(number))
|
||||
@@ -410,28 +419,28 @@ def format_number(number, **kwargs):
|
||||
# If the number cannot be converted to a Decimal, just return the original value
|
||||
return str(number)
|
||||
|
||||
if kwargs.get('integer', False):
|
||||
if integer:
|
||||
# Convert to integer
|
||||
number = Decimal(int(number))
|
||||
|
||||
# Normalize the number (remove trailing zeroes)
|
||||
number = number.normalize()
|
||||
|
||||
decimals = kwargs.get('decimal_places')
|
||||
|
||||
if decimals is not None:
|
||||
if decimal_places is not None:
|
||||
try:
|
||||
decimals = int(decimals)
|
||||
number = round(number, decimals)
|
||||
decimal_places = int(decimal_places)
|
||||
number = round(number, decimal_places)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Re-encode, and normalize again
|
||||
value = Decimal(number).normalize()
|
||||
value = format(value, 'f')
|
||||
value = str(value)
|
||||
|
||||
leading = kwargs.get('leading')
|
||||
if separator:
|
||||
value = f'{value:,}'
|
||||
value = value.replace(',', separator)
|
||||
else:
|
||||
value = f'{value}'
|
||||
|
||||
if leading is not None:
|
||||
try:
|
||||
@@ -444,37 +453,39 @@ def format_number(number, **kwargs):
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def format_datetime(datetime, timezone=None, fmt=None):
|
||||
def format_datetime(
|
||||
dt: datetime, timezone: Optional[str] = None, fmt: Optional[str] = None
|
||||
):
|
||||
"""Format a datetime object for display.
|
||||
|
||||
Arguments:
|
||||
datetime: The datetime object to format
|
||||
dt: The datetime object to format
|
||||
timezone: The timezone to use for the date (defaults to the server timezone)
|
||||
fmt: The format string to use (defaults to ISO formatting)
|
||||
"""
|
||||
datetime = InvenTree.helpers.to_local_time(datetime, timezone)
|
||||
dt = InvenTree.helpers.to_local_time(dt, timezone)
|
||||
|
||||
if fmt:
|
||||
return datetime.strftime(fmt)
|
||||
return dt.strftime(fmt)
|
||||
else:
|
||||
return datetime.isoformat()
|
||||
return dt.isoformat()
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def format_date(date, timezone=None, fmt=None):
|
||||
def format_date(dt: date, timezone: Optional[str] = None, fmt: Optional[str] = None):
|
||||
"""Format a date object for display.
|
||||
|
||||
Arguments:
|
||||
date: The date to format
|
||||
dt: The date to format
|
||||
timezone: The timezone to use for the date (defaults to the server timezone)
|
||||
fmt: The format string to use (defaults to ISO formatting)
|
||||
"""
|
||||
date = InvenTree.helpers.to_local_time(date, timezone).date()
|
||||
dt = InvenTree.helpers.to_local_time(dt, timezone).date()
|
||||
|
||||
if fmt:
|
||||
return date.strftime(fmt)
|
||||
return dt.strftime(fmt)
|
||||
else:
|
||||
return date.isoformat()
|
||||
return dt.isoformat()
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
|
||||
@@ -156,6 +156,18 @@ class ReportTagTest(TestCase):
|
||||
self.assertEqual(report_tags.multiply(2.3, 4), 9.2)
|
||||
self.assertEqual(report_tags.divide(100, 5), 20)
|
||||
|
||||
def test_number_tags(self):
|
||||
"""Simple tests for number formatting tags."""
|
||||
fn = report_tags.format_number
|
||||
|
||||
self.assertEqual(fn(1234), '1234')
|
||||
self.assertEqual(fn(1234.5678, decimal_places=2), '1234.57')
|
||||
self.assertEqual(fn(1234.5678, decimal_places=3), '1234.568')
|
||||
self.assertEqual(fn(-9999.5678, decimal_places=2, separator=','), '-9,999.57')
|
||||
self.assertEqual(
|
||||
fn(9988776655.4321, integer=True, separator=' '), '9 988 776 655'
|
||||
)
|
||||
|
||||
@override_settings(TIME_ZONE='America/New_York')
|
||||
def test_date_tags(self):
|
||||
"""Test for date formatting tags.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user