2
0
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:
Oliver Walters
2024-11-20 23:37:10 +00:00
472 changed files with 75725 additions and 69551 deletions
+2 -1
View File
@@ -31,7 +31,8 @@
"ms-python.python",
"ms-python.vscode-pylance",
"batisteo.vscode-django",
"eamodio.gitlens"
"eamodio.gitlens",
"biomejs.biome"
]
}
},
+4 -3
View File
@@ -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
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -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
+4
View File
@@ -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
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
{
"recommendations": [
"biomejs.biome"
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"editor.codeActionsOnSave": {
"quickfix.biome": "explicit"
}
}
+40
View File
@@ -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"
}
}
}
}
+4 -6
View File
@@ -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
+1
View File
@@ -71,6 +71,7 @@ services:
- 8000
depends_on:
- inventree-db
- inventree-cache
env_file:
- .env
volumes:
+22
View File
@@ -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.
+1 -1
View File
@@ -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
+9 -1
View File
@@ -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`.
+34 -1
View File
@@ -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.
+4
View File
@@ -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
View File
@@ -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:
+1 -1
View File
@@ -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
+6 -3
View File
@@ -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_ |
+10
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
+11 -1
View File
@@ -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
+5 -1
View File
@@ -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).
+101 -15
View File
@@ -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
+4 -14
View File
@@ -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."""
+2 -4
View 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:
+1 -1
View File
@@ -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):
+3 -3
View File
@@ -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,
+3 -3
View File
@@ -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
+34 -21
View File
@@ -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'),
),
]
+7
View File
@@ -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}'
+1 -1
View File
@@ -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 %}
+2 -2
View File
@@ -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'))
-2
View File
@@ -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,
+17 -3
View File
@@ -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):
+81 -56
View File
@@ -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}
+1 -1
View File
@@ -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
+3 -2
View File
@@ -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.
+17 -18
View File
@@ -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):
+3 -2
View File
@@ -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):
+1 -1
View File
@@ -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()
+12
View File
@@ -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